From b87b6edc4475364a0d29bd725640b44cb455da90 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Tue, 8 May 2018 09:47:33 -0700 Subject: [PATCH 001/152] Add support for TLS 1.2 on API 16+ --- .../launchdarkly/android/EventProcessor.java | 22 ++++- .../android/HttpFeatureFlagFetcher.java | 19 +++- .../android/tls/ModernTLSSocketFactory.java | 95 +++++++++++++++++++ .../android/tls/SSLHandshakeInterceptor.java | 35 +++++++ .../launchdarkly/android/tls/TLSUtils.java | 25 +++++ 5 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/ModernTLSSocketFactory.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/TLSUtils.java 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 02fb8683..0a9261f5 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 @@ -2,11 +2,16 @@ import android.content.Context; +import android.os.Build; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.android.tls.ModernTLSSocketFactory; +import com.launchdarkly.android.tls.SSLHandshakeInterceptor; +import com.launchdarkly.android.tls.TLSUtils; import java.io.Closeable; import java.io.IOException; +import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ArrayBlockingQueue; @@ -29,7 +34,7 @@ class EventProcessor implements Closeable { private final BlockingQueue queue; private final Consumer consumer; - private final OkHttpClient client; + private OkHttpClient client; private final Context context; private final LDConfig config; private ScheduledExecutorService scheduler; @@ -40,11 +45,22 @@ class EventProcessor implements Closeable { this.queue = new ArrayBlockingQueue<>(config.getEventsCapacity()); this.consumer = new Consumer(config); - client = new OkHttpClient.Builder() + OkHttpClient.Builder builder = new OkHttpClient.Builder() .connectionPool(new ConnectionPool(1, config.getEventsFlushIntervalMillis() * 2, TimeUnit.MILLISECONDS)) .connectTimeout(config.getConnectionTimeoutMillis(), TimeUnit.MILLISECONDS) .retryOnConnectionFailure(true) - .build(); + .addInterceptor(new SSLHandshakeInterceptor()); + + if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { + try { + builder.sslSocketFactory(new ModernTLSSocketFactory(), TLSUtils.defaultTrustManager()); + } catch (GeneralSecurityException ignored) { + // TLS is not available, so don't set up the socket factory, swallow the exception + } + } + + client = builder.build(); + } void start() { 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 3961a08a..abdafe3e 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 @@ -2,14 +2,18 @@ import android.content.Context; +import android.os.Build; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.launchdarkly.android.tls.ModernTLSSocketFactory; +import com.launchdarkly.android.tls.TLSUtils; import java.io.File; import java.io.IOException; +import java.security.GeneralSecurityException; import java.util.concurrent.TimeUnit; import okhttp3.Cache; @@ -56,11 +60,20 @@ private HttpFeatureFlagFetcher(Context context, LDConfig config) { File cacheDir = context.getCacheDir(); Timber.d("Using cache at: %s", cacheDir.getAbsolutePath()); - client = new OkHttpClient.Builder() + OkHttpClient.Builder builder = new OkHttpClient.Builder() .cache(new Cache(cacheDir, MAX_CACHE_SIZE_BYTES)) .connectionPool(new ConnectionPool(1, config.getBackgroundPollingIntervalMillis() * 2, TimeUnit.MILLISECONDS)) - .retryOnConnectionFailure(true) - .build(); + .retryOnConnectionFailure(true); + + if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { + try { + builder.sslSocketFactory(new ModernTLSSocketFactory(), TLSUtils.defaultTrustManager()); + } catch (GeneralSecurityException ignored) { + // TLS is not available, so don't set up the socket factory, swallow the exception + } + } + + client = builder.build(); } @Override 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 new file mode 100644 index 00000000..52e340fa --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/ModernTLSSocketFactory.java @@ -0,0 +1,95 @@ +package com.launchdarkly.android.tls; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +/** + * An {@link SSLSocketFactory} that tries to ensure modern TLS versions are used. + */ +public class ModernTLSSocketFactory extends SSLSocketFactory { + private static final String TLS_1_2 = "TLSv1.2"; + private static final String TLS_1_1 = "TLSv1.1"; + private static final String TLS_1 = "TLSv1"; + + private SSLSocketFactory defaultSocketFactory; + + public ModernTLSSocketFactory() throws NoSuchAlgorithmException, KeyManagementException { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, null, null); + this.defaultSocketFactory = context.getSocketFactory(); + } + + @Override + public String[] getDefaultCipherSuites() { + return this.defaultSocketFactory.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return this.defaultSocketFactory.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket socket, String s, int i, boolean b) throws IOException { + return setModernTlsVersionsOnSocket(this.defaultSocketFactory.createSocket(socket, s, i, b)); + } + + @Override + public Socket createSocket(String s, int i) throws IOException, UnknownHostException { + return setModernTlsVersionsOnSocket(this.defaultSocketFactory.createSocket(s, i)); + } + + @Override + public Socket createSocket(String s, int i, InetAddress inetAddress, int i1) throws IOException, UnknownHostException { + return setModernTlsVersionsOnSocket(this.defaultSocketFactory.createSocket(s, i, inetAddress, i1)); + } + + @Override + public Socket createSocket(InetAddress inetAddress, int i) throws IOException { + return setModernTlsVersionsOnSocket(this.defaultSocketFactory.createSocket(inetAddress, i)); + } + + @Override + public Socket createSocket(InetAddress inetAddress, int i, InetAddress inetAddress1, int i1) throws IOException { + return setModernTlsVersionsOnSocket(this.defaultSocketFactory.createSocket(inetAddress, i, inetAddress1, i1)); + } + + /** + * If either of TLSv1.2, TLSv1.1, or TLSv1 are supported, make them the only enabled protocols (listing in that order). + *

+ * If the socket does not make these modern TLS protocols available at all, then just return the socket unchanged. + * + * @param s the socket + * @return + */ + static Socket setModernTlsVersionsOnSocket(Socket s) { + if (s != null && (s instanceof SSLSocket)) { + List defaultEnabledProtocols = Arrays.asList(((SSLSocket) s).getSupportedProtocols()); + ArrayList newEnabledProtocols = new ArrayList<>(); + if (defaultEnabledProtocols.contains(TLS_1_2)) { + newEnabledProtocols.add(TLS_1_2); + } + if (defaultEnabledProtocols.contains(TLS_1_1)) { + newEnabledProtocols.add(TLS_1_1); + } + if (defaultEnabledProtocols.contains(TLS_1)) { + newEnabledProtocols.add(TLS_1); + } + if (newEnabledProtocols.size() > 0) { + ((SSLSocket) s).setEnabledProtocols(newEnabledProtocols.toArray(new String[newEnabledProtocols.size()])); + } + } + return s; + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java new file mode 100644 index 00000000..84eabce0 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java @@ -0,0 +1,35 @@ +package com.launchdarkly.android.tls; + +import android.support.annotation.NonNull; + +import java.io.IOException; + +import okhttp3.CipherSuite; +import okhttp3.Handshake; +import okhttp3.Response; +import okhttp3.TlsVersion; +import timber.log.Timber; + +/** + * Intercepts the SSL connection and prints TLS version and CipherSuite in the log. + */ +public class SSLHandshakeInterceptor implements okhttp3.Interceptor { + + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + final Response response = chain.proceed(chain.request()); + printTlsAndCipherSuiteInfo(response); + return response; + } + + private void printTlsAndCipherSuiteInfo(Response response) { + if (response != null) { + Handshake handshake = response.handshake(); + if (handshake != null) { + final CipherSuite cipherSuite = handshake.cipherSuite(); + final TlsVersion tlsVersion = handshake.tlsVersion(); + Timber.v("TLS: " + tlsVersion + ", CipherSuite: " + cipherSuite); + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..d98dc906 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/TLSUtils.java @@ -0,0 +1,25 @@ +package com.launchdarkly.android.tls; + +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.Arrays; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +public class TLSUtils { + + public static X509TrustManager defaultTrustManager() throws GeneralSecurityException { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:" + + Arrays.toString(trustManagers)); + } + return (X509TrustManager) trustManagers[0]; + } + +} From 2931d51e968f9d9f228392b2de43db7d15d846b6 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Wed, 9 May 2018 10:18:55 -0700 Subject: [PATCH 002/152] Add back final keyword --- .../src/main/java/com/launchdarkly/android/EventProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0a9261f5..7692be60 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 @@ -34,7 +34,7 @@ class EventProcessor implements Closeable { private final BlockingQueue queue; private final Consumer consumer; - private OkHttpClient client; + private final OkHttpClient client; private final Context context; private final LDConfig config; private ScheduledExecutorService scheduler; From 5bab855c96d530fea795ae409e08406ed95b3f68 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Wed, 9 May 2018 13:25:25 -0700 Subject: [PATCH 003/152] Bumping the minSdkVersion from 15 to 16 --- example/build.gradle | 2 +- launchdarkly-android-client/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/build.gradle b/example/build.gradle index 3a71b1c1..d1b28694 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -15,7 +15,7 @@ android { buildToolsVersion '26.0.2' defaultConfig { applicationId "com.launchdarkly.example" - minSdkVersion 15 + minSdkVersion 16 targetSdkVersion 26 versionCode 1 versionName "1.0" diff --git a/launchdarkly-android-client/build.gradle b/launchdarkly-android-client/build.gradle index 4ded28df..738afb1c 100644 --- a/launchdarkly-android-client/build.gradle +++ b/launchdarkly-android-client/build.gradle @@ -23,7 +23,7 @@ android { buildToolsVersion '26.0.2' defaultConfig { - minSdkVersion 15 + minSdkVersion 16 targetSdkVersion 26 versionCode 1 versionName version From 44eed4a5f11a52095d9a69e92a2f5bdfd34ecb6a Mon Sep 17 00:00:00 2001 From: nejifresh Date: Tue, 25 Sep 2018 08:13:50 -0600 Subject: [PATCH 004/152] Circle CI 2.0 --- .circleci/config.yml | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..ba534d8b --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,68 @@ +version: 2 +jobs: + build: + working_directory: ~/launchdarkly/android-client-private + docker: + - image: circleci/android:api-27-alpha + environment: + JVM_OPTS: -Xmx3200m + CIRCLE_ARTIFACTS: /tmp/circleci-artifacts + CIRCLE_TEST_REPORTS: /tmp/circleci-test-results + + steps: + - checkout + - restore_cache: + keys: + # This branch if available + - v1-dep-{{ .Branch }}- + # Default branch if not + - v1-dep-master- + # Any branch if there are none on the default branch - this should be unnecessary if you have your default branch configured correctly + - v1-dep- + + - run: + name: Download Dependencies + command: ./gradlew androidDependencies + - run: sudo mkdir -p $CIRCLE_TEST_REPORTS + - run: sudo apt-get -y -qq install awscli + - run: sudo mkdir -p /usr/local/android-sdk-linux/licenses + + - save_cache: + key: v1-dep-{{ .Branch }}-{{ epoch }} + paths: + # This is a broad list of cache paths to include many possible development environments + # You can probably delete some of these entries + - vendor/bundle + - ~/virtualenvs + - ~/.m2 + - ~/.ivy2 + - ~/.bundle + - ~/.go_workspace + - ~/.gradle + - ~/.cache/bower + # These cache paths were specified in the 1.0 config + - /usr/local/android-sdk-linux/platforms/android-26 + - /usr/local/android-sdk-linux/build-tools/26.0.2 + - /usr/local/android-sdk-linux/platforms/android-27 + - /usr/local/android-sdk-linux/build-tools/27.0.3 + - /usr/local/android-sdk-linux/extras/android/m2repository + - run: unset ANDROID_NDK_HOME + + - run: ./gradlew :launchdarkly-android-client:assembleDebug --console=plain -PdisablePreDex + - run: ./gradlew :launchdarkly-android-client:test --console=plain -PdisablePreDex + + - run: ./gradlew packageRelease --console=plain -PdisablePreDex + - run: + name: Run Tests + command: ./gradlew test + + - run: + name: Save test results + command: | + mkdir -p ~/tests/test-results + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/tests/test-results/ \; + when: always + - store_test_results: + path: ~/tests + - store_artifacts: + path: ~/tests From ba1fe523a112d4030135105f59382c528d66b622 Mon Sep 17 00:00:00 2001 From: torchhound Date: Mon, 1 Oct 2018 03:34:04 -0400 Subject: [PATCH 005/152] Created new branch for multi-environment sdk feature --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 432ca767..34be2551 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The LaunchDarkly Android SDK defaults to what we have found to be the best combi This configuration means that you will get near real-time updates for your feature flag values when the app is in the foreground. -###Other Options +### Other Options If you prefer other options, here they are: 1. Streaming can be disabled in favor of polling updates. To disable streaming call `.setStream(false)` on the `LDConfig.Builder` object. From c47d2d5efc871f120240c334fe6ff3dbb5f86fa0 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Mon, 1 Oct 2018 19:33:06 +0000 Subject: [PATCH 006/152] Stub out new LDConfig and LDConfig.Builder code for multi-environment. --- .../com/launchdarkly/android/LDConfig.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 e27828c4..4f3c6aad 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 @@ -7,6 +7,7 @@ import java.util.Collections; import java.util.HashSet; +import java.util.Map; import java.util.Set; import okhttp3.MediaType; @@ -33,6 +34,7 @@ public class LDConfig { static final int MIN_POLLING_INTERVAL_MILLIS = 300_000; // 5 minutes private final String mobileKey; + private final Map secondaryMobileKeys; private final Uri baseUri; private final Uri eventsUri; @@ -57,6 +59,7 @@ public class LDConfig { private final boolean inlineUsersInEvents; public LDConfig(String mobileKey, + Map secondaryMobileKeys, Uri baseUri, Uri eventsUri, Uri streamUri, @@ -74,6 +77,7 @@ public LDConfig(String mobileKey, boolean inlineUsersInEvents) { this.mobileKey = mobileKey; + this.secondaryMobileKeys = secondaryMobileKeys; this.baseUri = baseUri; this.eventsUri = eventsUri; this.streamUri = streamUri; @@ -106,6 +110,10 @@ public String getMobileKey() { return mobileKey; } + public Map getSecondaryMobileKeys() { + return secondaryMobileKeys; + } + public Uri getBaseUri() { return baseUri; } @@ -172,6 +180,7 @@ public boolean inlineUsersInEvents() { public static class Builder { private String mobileKey; + private Map secondaryMobileKeys; private Uri baseUri = DEFAULT_BASE_URI; private Uri eventsUri = DEFAULT_EVENTS_URI; @@ -217,10 +226,42 @@ public Builder setPrivateAttributeNames(Set privateAttributeNames) { * @return */ public LDConfig.Builder setMobileKey(String mobileKey) { + if (secondaryMobileKeys != null && secondaryMobileKeys.containsValue(mobileKey)) { + // Throw error about reuse of primary mobile key in secondary mobile keys + } + this.mobileKey = mobileKey; return this; } + /** + * 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 + */ + public LDConfig.Builder setSecondaryMobileKeys(Map secondaryMobileKeys) { + if (secondaryMobileKeys == null) { + this.secondaryMobileKeys = null; + return this; + } + + Map unmodifiable = Collections.unmodifiableMap(secondaryMobileKeys); + if (unmodifiable.containsKey(LDClient.primaryEnvironmentName)) { + // Throw error about primary environment name key reuse + } + Set secondaryKeys = new HashSet<>(unmodifiable.values()); + if (mobileKey != null && secondaryKeys.contains(mobileKey)) { + // Throw error about reuse of primary mobile key in secondary mobile keys + } + if (unmodifiable.values().size() != secondaryKeys.size()) { + // Throw error about key reuse within secondary mobile keys + } + + this.secondaryMobileKeys = unmodifiable; + return this; + } + /** * Sets the flag for choosing the REPORT api call. The default is GET. * Do not use unless advised by LaunchDarkly. @@ -405,6 +446,7 @@ public LDConfig build() { return new LDConfig( mobileKey, + secondaryMobileKeys, baseUri, eventsUri, streamUri, From 73e4b980d0ac573db240611aa9dfa2acb6259b3d Mon Sep 17 00:00:00 2001 From: torchhound Date: Mon, 1 Oct 2018 20:26:17 -0400 Subject: [PATCH 007/152] Added overloaded init method in LDClient for creating secondary instances --- .../com/launchdarkly/android/LDClient.java | 121 +++++++++++++++--- 1 file changed, 105 insertions(+), 16 deletions(-) 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 316467ab..a10f076c 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 @@ -23,6 +23,7 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutionException; @@ -43,7 +44,8 @@ public class LDClient implements LDClientInterface, Closeable { private static final String INSTANCE_ID_KEY = "instanceId"; // Upon client init will get set to a Unique id per installation used when creating anonymous users private static String instanceId = "UNKNOWN_ANDROID"; - private static LDClient instance = null; + private static LDClient primaryInstance = null; + private static Map secondaryInstances = new HashMap<>(); private static final long MAX_RETRY_TIME_MS = 3600000; // 1 hour private static final long RETRY_TIME_MS = 1000; // 1 second @@ -59,8 +61,10 @@ public class LDClient implements LDClientInterface, Closeable { private volatile boolean isOffline = false; private volatile boolean isAppForegrounded = true; + public static String primaryEnvironmentName = UUID.randomUUID().toString().replace("-", ""); + /** - * Initializes the singleton instance. The result is a {@link Future} which + * Initializes the singleton/primary instance. The result is a {@link Future} which * will complete once the client has been initialized with the latest feature flag values. For * immediate access to the Client (possibly with out of date feature flags), it is safe to ignore * the return value of this method, and afterward call {@link #get()} @@ -75,7 +79,6 @@ public class LDClient implements LDClientInterface, Closeable { * @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); @@ -91,32 +94,110 @@ public static synchronized Future init(@NonNull Application applicatio SettableFuture settableFuture = SettableFuture.create(); - if (instance != null) { + if (primaryInstance != null) { Timber.w( "LDClient.init() was called more than once! returning existing instance."); - settableFuture.set(instance); + settableFuture.set(primaryInstance); return settableFuture; } if (BuildConfig.DEBUG) { Timber.plant(new Timber.DebugTree()); } - instance = new LDClient(application, config); - instance.userManager.setCurrentUser(user); + primaryInstance = new LDClient(application, config); + primaryInstance.userManager.setCurrentUser(user); - if (instance.isOffline() || !isInternetConnected(application)) { - settableFuture.set(instance); + if (primaryInstance.isOffline() || !isInternetConnected(application)) { + settableFuture.set(primaryInstance); return settableFuture; + } + primaryInstance.eventProcessor.start(); + + ListenableFuture initFuture = primaryInstance.updateProcessor.start(); + primaryInstance.sendEvent(new IdentifyEvent(user)); + // Transform initFuture so its result is the instance: + return Futures.transform(initFuture, new Function() { + @Override + public LDClient apply(Void input) { + return primaryInstance; + } + }, MoreExecutors.directExecutor()); + } + + /** + * Initializes a secondary LDClient instance. The result is a {@link Future} which + * will complete once the client has been initialized with the latest feature flag values. For + * immediate access to the Client (possibly with out of date feature flags), it is safe to ignore + * the return value of this method, and afterward call {@link #get()} + *

+ * If the client has already been initialized, is configured for offline mode, or the device is + * not connected to the internet, this method will return a {@link Future} that is + * already in the completed state. + * + * @param application Your Android application. + * @param config Configuration used to set up the client + * @param user The user used in evaluating feature flags + * @param secondaryName Identifying name for the secondary LDClient instance + * @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, @NonNull String secondaryName) { + boolean applicationValid = validateParameter(application); + boolean configValid = validateParameter(config); + boolean userValid = validateParameter(user); + if (!applicationValid) { + return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid application")); + } + if (!configValid) { + return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid configuration")); + } + if (!userValid) { + return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid user")); + } + + SettableFuture settableFuture = SettableFuture.create(); + + if (primaryInstance != null) { + LDClient newSecondary = new LDClient(application, config); + newSecondary.userManager.setCurrentUser(user); + + if (primaryInstance.isOffline() || newSecondary.isOffline() || !isInternetConnected(application)) { + secondaryInstances.put(secondaryName, newSecondary); + settableFuture.set(primaryInstance); + return settableFuture; + } + newSecondary.eventProcessor.start(); + + ListenableFuture secondaryFuture = newSecondary.updateProcessor.start(); + newSecondary.sendEvent(new IdentifyEvent(user)); + + secondaryInstances.put(secondaryName, newSecondary); + + return Futures.transform(secondaryFuture, new Function() { + @Override + public LDClient apply(Void input) { + return primaryInstance; + } + }, MoreExecutors.directExecutor()); + } + if (BuildConfig.DEBUG) { + Timber.plant(new Timber.DebugTree()); } - instance.eventProcessor.start(); + primaryInstance = new LDClient(application, config); + primaryInstance.userManager.setCurrentUser(user); - ListenableFuture initFuture = instance.updateProcessor.start(); - instance.sendEvent(new IdentifyEvent(user)); + if (primaryInstance.isOffline() || !isInternetConnected(application)) { + settableFuture.set(primaryInstance); + return settableFuture; + } + primaryInstance.eventProcessor.start(); + + ListenableFuture initFuture = primaryInstance.updateProcessor.start(); + primaryInstance.sendEvent(new IdentifyEvent(user)); // Transform initFuture so its result is the instance: return Futures.transform(initFuture, new Function() { @Override public LDClient apply(Void input) { - return instance; + return primaryInstance; } }, MoreExecutors.directExecutor()); } @@ -155,7 +236,7 @@ public static synchronized LDClient init(Application application, LDConfig confi Timber.w("Client did not successfully initialize within " + startWaitSeconds + " seconds. " + "It could be taking longer than expected to start up"); } - return instance; + return primaryInstance; } /** @@ -163,11 +244,11 @@ public static synchronized LDClient init(Application application, LDConfig confi * @throws LaunchDarklyException if {@link #init(Application, LDConfig, LDUser)} has not been called. */ public static LDClient get() throws LaunchDarklyException { - if (instance == null) { + if (primaryInstance == null) { Timber.e("LDClient.get() was called before init()!"); throw new LaunchDarklyException("LDClient.get() was called before init()!"); } - return instance; + return primaryInstance; } @VisibleForTesting @@ -707,4 +788,12 @@ public void clearSummaryEventSharedPreferences() { public SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return userManager.getSummaryEventSharedPreferences(); } + + public static String getPrimaryEnvironmentName() { + return primaryEnvironmentName; + } + + public static void setPrimaryEnvironmentName(String primaryEnvironmentName) { + LDClient.primaryEnvironmentName = primaryEnvironmentName; + } } From 3962f105e7225b457ccdfe3b08b27c2253d818ac Mon Sep 17 00:00:00 2001 From: torchhound Date: Mon, 1 Oct 2018 20:36:01 -0400 Subject: [PATCH 008/152] Added getForMobileKey methods --- .../com/launchdarkly/android/LDClient.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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 a10f076c..30c38fed 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 @@ -202,6 +202,32 @@ public LDClient apply(Void input) { }, MoreExecutors.directExecutor()); } + private static synchronized Future getForMobileKey(String keyName) { + SettableFuture settableFuture = SettableFuture.create(); + LDClient client = secondaryInstances.get(keyName); + + if (client != null) { + settableFuture.set(client); + return settableFuture; + } + settableFuture.set(primaryInstance); + return settableFuture; + } + + private static LDClient getForMobileKey(String keyName, int startWaitSeconds) { + Future clientFuture = getForMobileKey(keyName); + + try { + return clientFuture.get(startWaitSeconds, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException e) { + Timber.e(e, "Exception during secondary instance retrieval"); + } catch (TimeoutException e) { + Timber.w("Secondary instance was not retrieved within " + startWaitSeconds + " seconds. " + + "It could be taking longer than expected to start up"); + } + return primaryInstance; + } + private static boolean validateParameter(T parameter) { boolean parameterValid; try { From c020ff9f4c128bcea4b3c95187c11dbd6aff4a51 Mon Sep 17 00:00:00 2001 From: torchhound Date: Mon, 1 Oct 2018 21:18:06 -0400 Subject: [PATCH 009/152] Added LDClient init test for secondary environment, added exception to getForMobileKeys, added multi-environment support to setOffline and setOnlineStatus (seems naive, needs testing), and added exceptions to LDConfig setSecondaryMobileKeys --- .../launchdarkly/android/LDClientTest.java | 26 ++++++++++++++--- .../com/launchdarkly/android/LDClient.java | 28 +++++++++++++++---- .../com/launchdarkly/android/LDConfig.java | 6 ++-- 3 files changed, 47 insertions(+), 13 deletions(-) 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 c3cf2bea..a4d9d4d9 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 @@ -40,7 +40,6 @@ public void setUp() { .build(); ldUser = new LDUser.Builder("userKey").build(); - } @UiThreadTest @@ -87,7 +86,6 @@ public void GivenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() throws E @UiThreadTest @Test public void TestInitMissingApplication() { - ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; @@ -110,7 +108,6 @@ public void TestInitMissingApplication() { @UiThreadTest @Test public void TestInitMissingConfig() { - ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; @@ -133,7 +130,6 @@ public void TestInitMissingConfig() { @UiThreadTest @Test public void TestInitMissingUser() { - ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; @@ -152,4 +148,26 @@ public void TestInitMissingUser() { assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); assertTrue("No future task to run", ldClientFuture.isDone()); } + + @UiThreadTest + @Test + public void TestInitSecondaryEnvironment() { + ExecutionException actualFutureException = null; + LaunchDarklyException actualProvidedException = null; + + ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, null, "testSecondaryEnvironment"); + + 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/main/java/com/launchdarkly/android/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index 30c38fed..a6ad8c89 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 @@ -25,6 +25,7 @@ import java.lang.ref.WeakReference; import java.util.HashMap; import java.util.Map; +import java.util.NoSuchElementException; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -62,6 +63,7 @@ public class LDClient implements LDClientInterface, Closeable { private volatile boolean isAppForegrounded = true; public static String primaryEnvironmentName = UUID.randomUUID().toString().replace("-", ""); + private String secondaryEnvironmentName; /** * Initializes the singleton/primary instance. The result is a {@link Future} which @@ -158,6 +160,7 @@ public static synchronized Future init(@NonNull Application applicatio if (primaryInstance != null) { LDClient newSecondary = new LDClient(application, config); newSecondary.userManager.setCurrentUser(user); + newSecondary.setSecondaryEnvironmentName(secondaryName); if (primaryInstance.isOffline() || newSecondary.isOffline() || !isInternetConnected(application)) { secondaryInstances.put(secondaryName, newSecondary); @@ -209,9 +212,12 @@ private static synchronized Future getForMobileKey(String keyName) { if (client != null) { settableFuture.set(client); return settableFuture; + } else if (keyName.equals(primaryEnvironmentName)) { + settableFuture.set(primaryInstance); + return settableFuture; + } else { + throw new NoSuchElementException(); } - settableFuture.set(primaryInstance); - return settableFuture; } private static LDClient getForMobileKey(String keyName, int startWaitSeconds) { @@ -657,6 +663,11 @@ public boolean isOffline() { @Override public synchronized void setOffline() { Timber.d("Setting isOffline = true"); + if (getSecondaryEnvironmentName() == null) { + for (LDClient client : secondaryInstances.values()) { + client.setOffline(); + } + } throttler.cancel(); isOffline = true; fetcher.setOffline(); @@ -679,6 +690,11 @@ public synchronized void setOnline() { private void setOnlineStatus() { Timber.d("Setting isOffline = false"); + if (getSecondaryEnvironmentName() == null) { + for (LDClient client : secondaryInstances.values()) { + client.setOnlineStatus(); + } + } isOffline = false; fetcher.setOnline(); if (isAppForegrounded) { @@ -815,11 +831,11 @@ public SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return userManager.getSummaryEventSharedPreferences(); } - public static String getPrimaryEnvironmentName() { - return primaryEnvironmentName; + public String getSecondaryEnvironmentName() { + return secondaryEnvironmentName; } - public static void setPrimaryEnvironmentName(String primaryEnvironmentName) { - LDClient.primaryEnvironmentName = primaryEnvironmentName; + public void setSecondaryEnvironmentName(String secondaryEnvironmentName) { + this.secondaryEnvironmentName = secondaryEnvironmentName; } } 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 4f3c6aad..efdd0219 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 @@ -248,14 +248,14 @@ public LDConfig.Builder setSecondaryMobileKeys(Map secondaryMobi Map unmodifiable = Collections.unmodifiableMap(secondaryMobileKeys); if (unmodifiable.containsKey(LDClient.primaryEnvironmentName)) { - // Throw error about primary environment name key reuse + throw new IllegalArgumentException("The primary environment name is not a valid key."); } Set secondaryKeys = new HashSet<>(unmodifiable.values()); if (mobileKey != null && secondaryKeys.contains(mobileKey)) { - // Throw error about reuse of primary mobile key in secondary mobile keys + throw new IllegalArgumentException("The primary environment name is not a valid key."); } if (unmodifiable.values().size() != secondaryKeys.size()) { - // Throw error about key reuse within secondary mobile keys + throw new IllegalArgumentException("A key can only be used once."); } this.secondaryMobileKeys = unmodifiable; From 19560146fe7b300dad341e52e2df4c0f4d08db84 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 2 Oct 2018 04:24:06 +0000 Subject: [PATCH 010/152] Move primary instance into general instances map. Begin on methods effecting all instances. --- .../com/launchdarkly/android/LDClient.java | 178 +++++++----------- 1 file changed, 68 insertions(+), 110 deletions(-) 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 a6ad8c89..2e373608 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 @@ -45,8 +45,7 @@ public class LDClient implements LDClientInterface, Closeable { private static final String INSTANCE_ID_KEY = "instanceId"; // Upon client init will get set to a Unique id per installation used when creating anonymous users private static String instanceId = "UNKNOWN_ANDROID"; - private static LDClient primaryInstance = null; - private static Map secondaryInstances = new HashMap<>(); + private static Map instances = null; private static final long MAX_RETRY_TIME_MS = 3600000; // 1 hour private static final long RETRY_TIME_MS = 1000; // 1 second @@ -62,8 +61,7 @@ public class LDClient implements LDClientInterface, Closeable { private volatile boolean isOffline = false; private volatile boolean isAppForegrounded = true; - public static String primaryEnvironmentName = UUID.randomUUID().toString().replace("-", ""); - private String secondaryEnvironmentName; + public static final String primaryEnvironmentName = UUID.randomUUID().toString().replace("-", ""); /** * Initializes the singleton/primary instance. The result is a {@link Future} which @@ -96,98 +94,36 @@ public static synchronized Future init(@NonNull Application applicatio SettableFuture settableFuture = SettableFuture.create(); - if (primaryInstance != null) { - Timber.w( "LDClient.init() was called more than once! returning existing instance."); - settableFuture.set(primaryInstance); + if (instances != null) { + Timber.w("LDClient.init() was called more than once! returning existing instance."); + settableFuture.set(instances.get(primaryEnvironmentName)); return settableFuture; } if (BuildConfig.DEBUG) { Timber.plant(new Timber.DebugTree()); } - primaryInstance = new LDClient(application, config); - primaryInstance.userManager.setCurrentUser(user); - if (primaryInstance.isOffline() || !isInternetConnected(application)) { - settableFuture.set(primaryInstance); - return settableFuture; - } - primaryInstance.eventProcessor.start(); + boolean internetConnected = isInternetConnected(application); + instances = new HashMap<>(); - ListenableFuture initFuture = primaryInstance.updateProcessor.start(); - primaryInstance.sendEvent(new IdentifyEvent(user)); + for (Map.Entry secondaryKeys : config.getSecondaryMobileKeys().entrySet()) { + final LDClient secondaryInstance = new LDClient(application, config, secondaryKeys.getKey()); + secondaryInstance.userManager.setCurrentUser(user); - // Transform initFuture so its result is the instance: - return Futures.transform(initFuture, new Function() { - @Override - public LDClient apply(Void input) { - return primaryInstance; - } - }, MoreExecutors.directExecutor()); - } + instances.put(secondaryKeys.getKey(), secondaryInstance); - /** - * Initializes a secondary LDClient instance. The result is a {@link Future} which - * will complete once the client has been initialized with the latest feature flag values. For - * immediate access to the Client (possibly with out of date feature flags), it is safe to ignore - * the return value of this method, and afterward call {@link #get()} - *

- * If the client has already been initialized, is configured for offline mode, or the device is - * not connected to the internet, this method will return a {@link Future} that is - * already in the completed state. - * - * @param application Your Android application. - * @param config Configuration used to set up the client - * @param user The user used in evaluating feature flags - * @param secondaryName Identifying name for the secondary LDClient instance - * @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, @NonNull String secondaryName) { - boolean applicationValid = validateParameter(application); - boolean configValid = validateParameter(config); - boolean userValid = validateParameter(user); - if (!applicationValid) { - return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid application")); - } - if (!configValid) { - return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid configuration")); - } - if (!userValid) { - return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid user")); - } + if (secondaryInstance.isOffline() || !internetConnected) + continue; - SettableFuture settableFuture = SettableFuture.create(); - - if (primaryInstance != null) { - LDClient newSecondary = new LDClient(application, config); - newSecondary.userManager.setCurrentUser(user); - newSecondary.setSecondaryEnvironmentName(secondaryName); - - if (primaryInstance.isOffline() || newSecondary.isOffline() || !isInternetConnected(application)) { - secondaryInstances.put(secondaryName, newSecondary); - settableFuture.set(primaryInstance); - return settableFuture; - } - newSecondary.eventProcessor.start(); - - ListenableFuture secondaryFuture = newSecondary.updateProcessor.start(); - newSecondary.sendEvent(new IdentifyEvent(user)); - - secondaryInstances.put(secondaryName, newSecondary); - - return Futures.transform(secondaryFuture, new Function() { - @Override - public LDClient apply(Void input) { - return primaryInstance; - } - }, MoreExecutors.directExecutor()); + secondaryInstance.eventProcessor.start(); + ListenableFuture initFuture = secondaryInstance.updateProcessor.start(); + secondaryInstance.sendEvent(new IdentifyEvent(user)); } - if (BuildConfig.DEBUG) { - Timber.plant(new Timber.DebugTree()); - } - primaryInstance = new LDClient(application, config); + + final LDClient primaryInstance = new LDClient(application, config); primaryInstance.userManager.setCurrentUser(user); - if (primaryInstance.isOffline() || !isInternetConnected(application)) { + if (primaryInstance.isOffline() || !internetConnected) { settableFuture.set(primaryInstance); return settableFuture; } @@ -196,6 +132,8 @@ public LDClient apply(Void input) { ListenableFuture initFuture = primaryInstance.updateProcessor.start(); primaryInstance.sendEvent(new IdentifyEvent(user)); + instances.put(primaryEnvironmentName, primaryInstance); + // Transform initFuture so its result is the instance: return Futures.transform(initFuture, new Function() { @Override @@ -205,16 +143,13 @@ public LDClient apply(Void input) { }, MoreExecutors.directExecutor()); } - private static synchronized Future getForMobileKey(String keyName) { + public static synchronized Future getForMobileKey(String keyName) { SettableFuture settableFuture = SettableFuture.create(); - LDClient client = secondaryInstances.get(keyName); + LDClient client = instances.get(keyName); if (client != null) { settableFuture.set(client); return settableFuture; - } else if (keyName.equals(primaryEnvironmentName)) { - settableFuture.set(primaryInstance); - return settableFuture; } else { throw new NoSuchElementException(); } @@ -231,7 +166,7 @@ private static LDClient getForMobileKey(String keyName, int startWaitSeconds) { Timber.w("Secondary instance was not retrieved within " + startWaitSeconds + " seconds. " + "It could be taking longer than expected to start up"); } - return primaryInstance; + return instances.get(keyName); } private static boolean validateParameter(T parameter) { @@ -268,7 +203,7 @@ public static synchronized LDClient init(Application application, LDConfig confi Timber.w("Client did not successfully initialize within " + startWaitSeconds + " seconds. " + "It could be taking longer than expected to start up"); } - return primaryInstance; + return instances.get(primaryEnvironmentName); } /** @@ -276,15 +211,20 @@ public static synchronized LDClient init(Application application, LDConfig confi * @throws LaunchDarklyException if {@link #init(Application, LDConfig, LDUser)} has not been called. */ public static LDClient get() throws LaunchDarklyException { - if (primaryInstance == null) { + if (instances == null) { Timber.e("LDClient.get() was called before init()!"); throw new LaunchDarklyException("LDClient.get() was called before init()!"); } - return primaryInstance; + return instances.get(primaryEnvironmentName); } @VisibleForTesting protected LDClient(final Application application, @NonNull final LDConfig config) { + this(application, config, primaryEnvironmentName); + } + + @VisibleForTesting + protected LDClient(final Application application, @NonNull final LDConfig config, String environmentName) { Timber.i("Creating LaunchDarkly client. Version: %s", BuildConfig.VERSION_NAME); this.config = config; this.isOffline = config.isOffline(); @@ -629,18 +569,44 @@ public JsonElement jsonVariation(String flagKey, JsonElement fallback) { */ @Override public void close() throws IOException { + LDClient.closeInstances(); + } + + private void closeInternal() throws IOException { updateProcessor.stop(); eventProcessor.close(); } + public static void closeInstances() { + for (LDClient client : instances.values()) { + try { + client.closeInternal(); + } catch (IOException e) { + // TODO(gavwhela) handle IOException + e.printStackTrace(); + } + } + } + /** * Sends all pending events to LaunchDarkly. */ @Override public void flush() { + LDClient.flushInstances(); + } + + private void flushInternal() { eventProcessor.flush(); } + public static void flushInstances() { + for (LDClient client : instances.values()) { + client.flushInternal(); + } + + } + @Override public boolean isInitialized() { return isOffline() || updateProcessor.isInitialized(); @@ -662,12 +628,11 @@ public boolean isOffline() { */ @Override public synchronized void setOffline() { + LDClient.setInstancesOffline(); + } + + private synchronized void setOfflineInternal() { Timber.d("Setting isOffline = true"); - if (getSecondaryEnvironmentName() == null) { - for (LDClient client : secondaryInstances.values()) { - client.setOffline(); - } - } throttler.cancel(); isOffline = true; fetcher.setOffline(); @@ -675,6 +640,12 @@ public synchronized void setOffline() { eventProcessor.stop(); } + public synchronized static void setInstancesOffline() { + for (LDClient client : instances.values()) { + client.setOfflineInternal(); + } + } + /** * 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. @@ -690,11 +661,6 @@ public synchronized void setOnline() { private void setOnlineStatus() { Timber.d("Setting isOffline = false"); - if (getSecondaryEnvironmentName() == null) { - for (LDClient client : secondaryInstances.values()) { - client.setOnlineStatus(); - } - } isOffline = false; fetcher.setOnline(); if (isAppForegrounded) { @@ -830,12 +796,4 @@ public void clearSummaryEventSharedPreferences() { public SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return userManager.getSummaryEventSharedPreferences(); } - - public String getSecondaryEnvironmentName() { - return secondaryEnvironmentName; - } - - public void setSecondaryEnvironmentName(String secondaryEnvironmentName) { - this.secondaryEnvironmentName = secondaryEnvironmentName; - } } From cf10753c7d8f3641639f7f526a4e2bbbfdadd7c3 Mon Sep 17 00:00:00 2001 From: torchhound Date: Tue, 2 Oct 2018 00:51:13 -0400 Subject: [PATCH 011/152] Removed incorrect LDClient test --- .../launchdarkly/android/LDClientTest.java | 22 ------------------- 1 file changed, 22 deletions(-) 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 a4d9d4d9..45e9766e 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 @@ -148,26 +148,4 @@ public void TestInitMissingUser() { assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); assertTrue("No future task to run", ldClientFuture.isDone()); } - - @UiThreadTest - @Test - public void TestInitSecondaryEnvironment() { - ExecutionException actualFutureException = null; - LaunchDarklyException actualProvidedException = null; - - ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, null, "testSecondaryEnvironment"); - - 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()); - } } From a09191d1b41de254ea9f28231c62107c9f1299bd Mon Sep 17 00:00:00 2001 From: torchhound Date: Tue, 2 Oct 2018 17:28:22 -0400 Subject: [PATCH 012/152] Fixed 1 failing test in LDClient, added multi-environment test file, fixed some linter issues --- .../launchdarkly/android/LDClientTest.java | 15 +- .../android/MultiEnvironmentLDClientTest.java | 156 ++++++++++++++++++ .../com/launchdarkly/android/LDClient.java | 20 ++- 3 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MultiEnvironmentLDClientTest.java 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 45e9766e..73837a1c 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 @@ -16,6 +16,7 @@ import java.util.concurrent.Future; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; import static org.hamcrest.CoreMatchers.instanceOf; @@ -45,7 +46,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() throws ExecutionException, InterruptedException { + public void TestOfflineClientReturnsFallbacks() { ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); ldClient.clearSummaryEventSharedPreferences(); @@ -67,18 +68,18 @@ public void TestOfflineClientReturnsFallbacks() throws ExecutionException, Inter @UiThreadTest // Not testing UI things, but we need to simulate the UI so the Foreground class is happy. @Test - public void GivenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() throws ExecutionException, InterruptedException { + public void GivenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() { ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); ldClient.clearSummaryEventSharedPreferences(); assertTrue(ldClient.isInitialized()); assertTrue(ldClient.isOffline()); - assertEquals(null, ldClient.jsonVariation("jsonFlag", null)); + assertNull(ldClient.jsonVariation("jsonFlag", null)); - assertEquals(null, ldClient.boolVariation("boolFlag", null)); - assertEquals(null, ldClient.floatVariation("floatFlag", null)); - assertEquals(null, ldClient.intVariation("intFlag", null)); - assertEquals(null, ldClient.stringVariation("stringFlag", null)); + assertNull(ldClient.boolVariation("boolFlag", null)); + assertNull(ldClient.floatVariation("floatFlag", null)); + assertNull(ldClient.intVariation("intFlag", null)); + assertNull(ldClient.stringVariation("stringFlag", null)); ldClient.clearSummaryEventSharedPreferences(); } 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 new file mode 100644 index 00000000..5c58efa6 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MultiEnvironmentLDClientTest.java @@ -0,0 +1,156 @@ +package com.launchdarkly.android; + +import android.support.test.annotation.UiThreadTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.google.gson.JsonObject; +import com.launchdarkly.android.test.TestActivity; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; +import static junit.framework.Assert.fail; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.MatcherAssert.assertThat; + +@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(); + } + + 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(); + } + + 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(); + } + + 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/main/java/com/launchdarkly/android/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index 2e373608..9a1d0c0b 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 @@ -106,18 +106,20 @@ public static synchronized Future init(@NonNull Application applicatio boolean internetConnected = isInternetConnected(application); instances = new HashMap<>(); - for (Map.Entry secondaryKeys : config.getSecondaryMobileKeys().entrySet()) { - final LDClient secondaryInstance = new LDClient(application, config, secondaryKeys.getKey()); - secondaryInstance.userManager.setCurrentUser(user); + if (config.getSecondaryMobileKeys() != null) { + for (Map.Entry secondaryKeys : config.getSecondaryMobileKeys().entrySet()) { + final LDClient secondaryInstance = new LDClient(application, config, secondaryKeys.getKey()); + secondaryInstance.userManager.setCurrentUser(user); - instances.put(secondaryKeys.getKey(), secondaryInstance); + instances.put(secondaryKeys.getKey(), secondaryInstance); - if (secondaryInstance.isOffline() || !internetConnected) - continue; + if (secondaryInstance.isOffline() || !internetConnected) + continue; - secondaryInstance.eventProcessor.start(); - ListenableFuture initFuture = secondaryInstance.updateProcessor.start(); - secondaryInstance.sendEvent(new IdentifyEvent(user)); + secondaryInstance.eventProcessor.start(); + ListenableFuture initFuture = secondaryInstance.updateProcessor.start(); + secondaryInstance.sendEvent(new IdentifyEvent(user)); + } } final LDClient primaryInstance = new LDClient(application, config); From e318b611f391325a4fe3ecd4a48ec492d4bc118d Mon Sep 17 00:00:00 2001 From: torchhound Date: Tue, 2 Oct 2018 18:22:10 -0400 Subject: [PATCH 013/152] Removed unnecessary ListenableFuture in LDClient init, changed identify method for multi-environment, not sure about SettableFuture null vs null for return type of Future, both type check and pass tests --- .../java/com/launchdarkly/android/LDClient.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 9a1d0c0b..1c5a0d43 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 @@ -117,7 +117,7 @@ public static synchronized Future init(@NonNull Application applicatio continue; secondaryInstance.eventProcessor.start(); - ListenableFuture initFuture = secondaryInstance.updateProcessor.start(); + secondaryInstance.updateProcessor.start(); secondaryInstance.sendEvent(new IdentifyEvent(user)); } } @@ -321,6 +321,10 @@ public void track(String eventName) { */ @Override public synchronized Future identify(LDUser user) { + return identifyInstances(user); + } + + private synchronized Future identifyInternal(LDUser user) { if (user == null) { return Futures.immediateFailedFuture(new LaunchDarklyException("User cannot be null")); } @@ -343,6 +347,15 @@ public synchronized Future identify(LDUser user) { return doneFuture; } + private synchronized Future identifyInstances(LDUser user) { + SettableFuture voidFuture = SettableFuture.create(); + for (LDClient client : instances.values()) { + client.identifyInternal(user); + } + voidFuture.set(null); + return voidFuture; //TODO(jcieslik) Null type checks for Future, that would be cleaner + } + /** * Returns a map of all feature flags for the current user. No events are sent to LaunchDarkly. * From 0fbe675cc11128c4cc4bea6d716bfd9aff62bde7 Mon Sep 17 00:00:00 2001 From: torchhound Date: Tue, 2 Oct 2018 21:56:29 -0400 Subject: [PATCH 014/152] Added setOnlineStatus multi-environment changes --- .../main/java/com/launchdarkly/android/LDClient.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 1c5a0d43..1f4fd4aa 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 @@ -61,7 +61,7 @@ public class LDClient implements LDClientInterface, Closeable { private volatile boolean isOffline = false; private volatile boolean isAppForegrounded = true; - public static final String primaryEnvironmentName = UUID.randomUUID().toString().replace("-", ""); + static final String primaryEnvironmentName = "default"; /** * Initializes the singleton/primary instance. The result is a {@link Future} which @@ -675,6 +675,10 @@ public synchronized void setOnline() { } private void setOnlineStatus() { + LDClient.setOnlineStatusInstances(); + } + + private void setOnlineStatusInternal() { Timber.d("Setting isOffline = false"); isOffline = false; fetcher.setOnline(); @@ -686,6 +690,12 @@ private void setOnlineStatus() { eventProcessor.start(); } + private static void setOnlineStatusInstances() { + for (LDClient client : instances.values()) { + client.setOnlineStatusInternal(); + } + } + /** * 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. From afe797af3a013d20f99eac139302a2963faf81a6 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Sat, 6 Oct 2018 22:38:05 +0000 Subject: [PATCH 015/152] Moved primaryEnvironmentName to LDConfig, simplifying by removing primaryKey separation everywhere but the builder. Add getRequestBuilderFor a specific environment. Add static method to LDClient to get all environment names so that environments can be iterated over. Add accessor to retrieve LDClient specific UserManager. Iterate over all environments in PollingUpdater. Add environment argument to UserManager constructor, removing singleton and creating replacing init with newInstance static method. --- .../com/launchdarkly/android/LDClient.java | 56 +++++++++++-------- .../com/launchdarkly/android/LDConfig.java | 45 ++++++++++----- .../launchdarkly/android/PollingUpdater.java | 14 +++-- .../com/launchdarkly/android/UserManager.java | 19 +------ 4 files changed, 75 insertions(+), 59 deletions(-) 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 1f4fd4aa..dd3fc3be 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 @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -61,8 +62,6 @@ public class LDClient implements LDClientInterface, Closeable { private volatile boolean isOffline = false; private volatile boolean isAppForegrounded = true; - static final String primaryEnvironmentName = "default"; - /** * Initializes the singleton/primary instance. The result is a {@link Future} which * will complete once the client has been initialized with the latest feature flag values. For @@ -96,7 +95,7 @@ public static synchronized Future init(@NonNull Application applicatio if (instances != null) { Timber.w("LDClient.init() was called more than once! returning existing instance."); - settableFuture.set(instances.get(primaryEnvironmentName)); + settableFuture.set(instances.get(LDConfig.primaryEnvironmentName)); return settableFuture; } if (BuildConfig.DEBUG) { @@ -106,35 +105,32 @@ public static synchronized Future init(@NonNull Application applicatio boolean internetConnected = isInternetConnected(application); instances = new HashMap<>(); - if (config.getSecondaryMobileKeys() != null) { - for (Map.Entry secondaryKeys : config.getSecondaryMobileKeys().entrySet()) { - final LDClient secondaryInstance = new LDClient(application, config, secondaryKeys.getKey()); - secondaryInstance.userManager.setCurrentUser(user); + // TODO(gavwhela) Handle futures merging + Map> updateFutures = new HashMap<>(); - instances.put(secondaryKeys.getKey(), secondaryInstance); + for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { + final LDClient instance = new LDClient(application, config, mobileKeys.getKey()); + instance.userManager.setCurrentUser(user); - if (secondaryInstance.isOffline() || !internetConnected) - continue; + instances.put(mobileKeys.getKey(), instance); - secondaryInstance.eventProcessor.start(); - secondaryInstance.updateProcessor.start(); - secondaryInstance.sendEvent(new IdentifyEvent(user)); - } + if (instance.isOffline() || !internetConnected) + continue; + + instance.eventProcessor.start(); + updateFutures.put(mobileKeys.getKey(), instance.updateProcessor.start()); + instance.sendEvent(new IdentifyEvent(user)); } - final LDClient primaryInstance = new LDClient(application, config); - primaryInstance.userManager.setCurrentUser(user); + final LDClient primaryInstance = instances.get(LDConfig.primaryEnvironmentName); if (primaryInstance.isOffline() || !internetConnected) { settableFuture.set(primaryInstance); return settableFuture; } - primaryInstance.eventProcessor.start(); - ListenableFuture initFuture = primaryInstance.updateProcessor.start(); primaryInstance.sendEvent(new IdentifyEvent(user)); - - instances.put(primaryEnvironmentName, primaryInstance); + ListenableFuture initFuture = updateFutures.get(LDConfig.primaryEnvironmentName); // Transform initFuture so its result is the instance: return Futures.transform(initFuture, new Function() { @@ -145,6 +141,14 @@ public LDClient apply(Void input) { }, MoreExecutors.directExecutor()); } + public static Set getEnvironmentNames() throws LaunchDarklyException{ + if (instances == null) { + Timber.e("LDClient.getEnvironmentNames() was called before init()!"); + throw new LaunchDarklyException("LDClient.getEnvironmentNames() was called before init()!"); + } + return instances.keySet(); + } + public static synchronized Future getForMobileKey(String keyName) { SettableFuture settableFuture = SettableFuture.create(); LDClient client = instances.get(keyName); @@ -205,7 +209,7 @@ public static synchronized LDClient init(Application application, LDConfig confi Timber.w("Client did not successfully initialize within " + startWaitSeconds + " seconds. " + "It could be taking longer than expected to start up"); } - return instances.get(primaryEnvironmentName); + return instances.get(LDConfig.primaryEnvironmentName); } /** @@ -217,12 +221,12 @@ public static LDClient get() throws LaunchDarklyException { Timber.e("LDClient.get() was called before init()!"); throw new LaunchDarklyException("LDClient.get() was called before init()!"); } - return instances.get(primaryEnvironmentName); + return instances.get(LDConfig.primaryEnvironmentName); } @VisibleForTesting protected LDClient(final Application application, @NonNull final LDConfig config) { - this(application, config, primaryEnvironmentName); + this(application, config, LDConfig.primaryEnvironmentName); } @VisibleForTesting @@ -246,7 +250,7 @@ protected LDClient(final Application application, @NonNull final LDConfig config Timber.i("Using instance id: " + instanceId); this.fetcher = HttpFeatureFlagFetcher.init(application, config); - this.userManager = UserManager.init(application, fetcher); + this.userManager = UserManager.newInstance(application, fetcher, environmentName); Foreground foreground = Foreground.get(application); Foreground.Listener foregroundListener = new Foreground.Listener() { @Override @@ -821,4 +825,8 @@ public void clearSummaryEventSharedPreferences() { public SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return userManager.getSummaryEventSharedPreferences(); } + + public UserManager getUserManager() { + return userManager; + } } 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 efdd0219..8adba2e4 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 @@ -6,6 +6,7 @@ import com.google.gson.GsonBuilder; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -21,20 +22,21 @@ public class LDConfig { static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); static final Gson GSON = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + static final String primaryEnvironmentName = "default"; + static final Uri DEFAULT_BASE_URI = Uri.parse("https://app.launchdarkly.com"); static final Uri DEFAULT_EVENTS_URI = Uri.parse("https://mobile.launchdarkly.com/mobile"); static final Uri DEFAULT_STREAM_URI = Uri.parse("https://clientstream.launchdarkly.com"); static final int DEFAULT_EVENTS_CAPACITY = 100; - static final int DEFAULT_FLUSH_INTERVAL_MILLIS = 30000; - static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = 10000; + static final int DEFAULT_FLUSH_INTERVAL_MILLIS = 30_000; // 30 seconds + static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = 10_000; // 10 seconds static final int DEFAULT_POLLING_INTERVAL_MILLIS = 300_000; // 5 minutes static final int DEFAULT_BACKGROUND_POLLING_INTERVAL_MILLIS = 3_600_000; // 1 hour static final int MIN_BACKGROUND_POLLING_INTERVAL_MILLIS = 900_000; // 15 minutes static final int MIN_POLLING_INTERVAL_MILLIS = 300_000; // 5 minutes - private final String mobileKey; - private final Map secondaryMobileKeys; + private final Map mobileKeys; private final Uri baseUri; private final Uri eventsUri; @@ -58,8 +60,7 @@ public class LDConfig { private final boolean inlineUsersInEvents; - public LDConfig(String mobileKey, - Map secondaryMobileKeys, + public LDConfig(Map mobileKeys, Uri baseUri, Uri eventsUri, Uri streamUri, @@ -76,8 +77,7 @@ public LDConfig(String mobileKey, Set privateAttributeNames, boolean inlineUsersInEvents) { - this.mobileKey = mobileKey; - this.secondaryMobileKeys = secondaryMobileKeys; + this.mobileKeys = mobileKeys; this.baseUri = baseUri; this.eventsUri = eventsUri; this.streamUri = streamUri; @@ -102,16 +102,29 @@ public LDConfig(String mobileKey, public Request.Builder getRequestBuilder() { return new Request.Builder() - .addHeader("Authorization", mobileKey) + .addHeader("Authorization", getMobileKey()) + .addHeader("User-Agent", USER_AGENT_HEADER_VALUE); + } + + public Request.Builder getRequestBuilderFor(String environment) { + if (environment == null || environment.equals(primaryEnvironmentName)) + return getRequestBuilder(); + + String key = mobileKeys.get(environment); + if (key == null) + throw new IllegalArgumentException("No environment by that name."); + + return new Request.Builder() + .addHeader("Authorization", key) .addHeader("User-Agent", USER_AGENT_HEADER_VALUE); } public String getMobileKey() { - return mobileKey; + return mobileKeys.get(primaryEnvironmentName); } - public Map getSecondaryMobileKeys() { - return secondaryMobileKeys; + public Map getMobileKeys() { + return mobileKeys; } public Uri getBaseUri() { @@ -247,7 +260,7 @@ public LDConfig.Builder setSecondaryMobileKeys(Map secondaryMobi } Map unmodifiable = Collections.unmodifiableMap(secondaryMobileKeys); - if (unmodifiable.containsKey(LDClient.primaryEnvironmentName)) { + if (unmodifiable.containsKey(primaryEnvironmentName)) { throw new IllegalArgumentException("The primary environment name is not a valid key."); } Set secondaryKeys = new HashSet<>(unmodifiable.values()); @@ -444,8 +457,12 @@ public LDConfig build() { PollingUpdater.backgroundPollingIntervalMillis = backgroundPollingIntervalMillis; + if (secondaryMobileKeys == null) { + secondaryMobileKeys = new HashMap<>(); + } + secondaryMobileKeys.put(primaryEnvironmentName, mobileKey); + return new LDConfig( - mobileKey, secondaryMobileKeys, baseUri, eventsUri, diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java index b46bb9e3..0d5e0332 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java @@ -7,6 +7,7 @@ import android.content.Intent; import android.os.SystemClock; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -26,12 +27,15 @@ public void onReceive(Context context, Intent intent) { LDClient client = LDClient.get(); if (client != null && !client.isOffline() && isInternetConnected(context)) { Timber.d("onReceive connected to the internet!"); - UserManager userManager = UserManager.get(); - if (userManager == null) { - Timber.e("UserManager singleton was accessed before it was initialized! doing nothing"); - return; + Set environments = LDClient.getEnvironmentNames(); + for (String environment : environments) { + UserManager userManager = LDClient.getForMobileKey(environment).get().getUserManager(); + if (userManager == null) { + Timber.e("UserManager singleton was accessed before it was initialized! doing nothing"); + continue; + } + userManager.updateCurrentUser().get(15, TimeUnit.SECONDS); } - userManager.updateCurrentUser().get(15, TimeUnit.SECONDS); } else { Timber.d("onReceive with no internet connection! Skipping fetch."); } 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 8a18662c..04c6229e 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 @@ -1,7 +1,5 @@ package com.launchdarkly.android; - -import android.annotation.SuppressLint; import android.app.Application; import android.content.SharedPreferences; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; @@ -48,9 +46,6 @@ */ class UserManager { - @SuppressLint("StaticFieldLeak") - private static UserManager instance; - private final FeatureFlagFetcher fetcher; private volatile boolean initialized = false; @@ -64,19 +59,11 @@ class UserManager { private final ExecutorService executor; - static synchronized UserManager init(Application application, FeatureFlagFetcher fetcher) { - if (instance != null) { - return instance; - } - instance = new UserManager(application, fetcher); - return instance; - } - - static UserManager get() { - return instance; + static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher, String environment) { + return new UserManager(application, fetcher, environment); } - UserManager(Application application, FeatureFlagFetcher fetcher) { + UserManager(Application application, FeatureFlagFetcher fetcher, String environment) { this.application = application; this.fetcher = fetcher; this.userLocalSharedPreferences = new UserLocalSharedPreferences(application); From babd1cd39abc0a494c71acb639aaec8be5bba11e Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Sat, 6 Oct 2018 22:47:57 +0000 Subject: [PATCH 016/152] Add back in constructor without environment to UserManager --- .../src/main/java/com/launchdarkly/android/UserManager.java | 4 ++++ 1 file changed, 4 insertions(+) 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 04c6229e..a4b5eb37 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 @@ -63,6 +63,10 @@ static synchronized UserManager newInstance(Application application, FeatureFlag return new UserManager(application, fetcher, environment); } + UserManager(Application application, FeatureFlagFetcher fetcher) { + this(application, fetcher, LDConfig.primaryEnvironmentName); + } + UserManager(Application application, FeatureFlagFetcher fetcher, String environment) { this.application = application; this.fetcher = fetcher; From 33fd8d7962ac56fc0d6c827c839973bf5f07d4ca Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Sat, 6 Oct 2018 23:04:18 +0000 Subject: [PATCH 017/152] Specialize HttpFeatureFlagFetcher to the environment, looks like UserManager may not be able to do the same so removed the environment from the constructor. --- .../android/HttpFeatureFlagFetcher.java | 30 +++++++++---------- .../com/launchdarkly/android/LDClient.java | 4 +-- .../com/launchdarkly/android/UserManager.java | 8 ++--- 3 files changed, 18 insertions(+), 24 deletions(-) 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 99ad716a..8c98ea88 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 @@ -1,8 +1,8 @@ package com.launchdarkly.android; - import android.content.Context; import android.os.Build; +import android.support.annotation.NonNull; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; @@ -35,25 +35,20 @@ class HttpFeatureFlagFetcher implements FeatureFlagFetcher { private static final int MAX_CACHE_SIZE_BYTES = 500_000; - private static HttpFeatureFlagFetcher instance; - private final LDConfig config; + private final String environment; private final Context context; private final OkHttpClient client; private volatile boolean isOffline = false; - static HttpFeatureFlagFetcher init(Context context, LDConfig config) { - instance = new HttpFeatureFlagFetcher(context, config); - return instance; + static HttpFeatureFlagFetcher newInstance(Context context, LDConfig config, String environment) { + return new HttpFeatureFlagFetcher(context, config, environment); } - static HttpFeatureFlagFetcher get() { - return instance; - } - - private HttpFeatureFlagFetcher(Context context, LDConfig config) { + private HttpFeatureFlagFetcher(Context context, LDConfig config, String environment) { this.config = config; + this.environment = environment; this.context = context; this.isOffline = config.isOffline(); @@ -82,19 +77,21 @@ public synchronized ListenableFuture fetch(LDUser user) { if (user != null && !isOffline && isInternetConnected(context)) { - final Request request = config.isUseReport() ? getReportRequest(user) : getDefaultRequest(user); + final Request request = config.isUseReport() + ? getReportRequest(user) + : getDefaultRequest(user); Timber.d(request.toString()); Call call = client.newCall(request); call.enqueue(new Callback() { @Override - public void onFailure(Call call, IOException e) { + public void onFailure(@NonNull Call call, @NonNull IOException e) { Timber.e(e, "Exception when fetching flags."); doneFuture.setException(e); } @Override - public void onResponse(Call call, final Response response) throws IOException { + public void onResponse(@NonNull Call call, @NonNull final Response response) throws IOException { String body = ""; try { ResponseBody responseBody = response.body(); @@ -139,7 +136,7 @@ public void onResponse(Call call, final Response response) throws IOException { private Request getDefaultRequest(LDUser user) { String uri = config.getBaseUri() + "/msdk/evalx/users/" + user.getAsUrlSafeBase64(); Timber.d("Attempting to fetch Feature flags using uri: %s", uri); - final Request request = config.getRequestBuilder() // default GET verb + final Request request = config.getRequestBuilderFor(environment) // default GET verb .url(uri) .build(); return request; @@ -150,13 +147,14 @@ private Request getReportRequest(LDUser user) { 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.getRequestBuilder() + final Request report = config.getRequestBuilderFor(environment) .method("REPORT", reportBody) // custom REPORT verb .url(reportUri) .build(); return report; } + @Override public void setOffline() { isOffline = true; 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 dd3fc3be..24adfdf0 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 @@ -249,8 +249,8 @@ protected LDClient(final Application application, @NonNull final LDConfig config instanceId = instanceIdSharedPrefs.getString(INSTANCE_ID_KEY, instanceId); Timber.i("Using instance id: " + instanceId); - this.fetcher = HttpFeatureFlagFetcher.init(application, config); - this.userManager = UserManager.newInstance(application, fetcher, environmentName); + this.fetcher = HttpFeatureFlagFetcher.newInstance(application, config, environmentName); + this.userManager = UserManager.newInstance(application, fetcher); Foreground foreground = Foreground.get(application); Foreground.Listener foregroundListener = new Foreground.Listener() { @Override 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 a4b5eb37..39912d06 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 @@ -59,15 +59,11 @@ class UserManager { private final ExecutorService executor; - static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher, String environment) { - return new UserManager(application, fetcher, environment); + static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher) { + return new UserManager(application, fetcher); } UserManager(Application application, FeatureFlagFetcher fetcher) { - this(application, fetcher, LDConfig.primaryEnvironmentName); - } - - UserManager(Application application, FeatureFlagFetcher fetcher, String environment) { this.application = application; this.fetcher = fetcher; this.userLocalSharedPreferences = new UserLocalSharedPreferences(application); From eb93b8c6a82402c4410668c0680ea6d20d2adf23 Mon Sep 17 00:00:00 2001 From: torchhound Date: Sat, 6 Oct 2018 19:46:12 -0400 Subject: [PATCH 018/152] Added SharedPreferences migration strategy --- .../com/launchdarkly/android/LDClient.java | 29 +++++++++++++++++-- .../android/UserLocalSharePreferences.java | 2 -- .../com/launchdarkly/android/UserManager.java | 3 ++ 3 files changed, 29 insertions(+), 5 deletions(-) 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 dd3fc3be..b175e56b 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 @@ -237,16 +237,39 @@ protected LDClient(final Application application, @NonNull final LDConfig config this.application = new WeakReference<>(application); SharedPreferences instanceIdSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE); + SharedPreferences mobileKeySharedPrefs = + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + config.getMobileKeys().get(environmentName), Context.MODE_PRIVATE); + + if (instanceIdSharedPrefs != null) { + SharedPreferences.Editor editor = mobileKeySharedPrefs.edit(); + + for (Map.Entry entry : instanceIdSharedPrefs.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)); + editor.apply(); + } + } - if (!instanceIdSharedPrefs.contains(INSTANCE_ID_KEY)) { + if (!mobileKeySharedPrefs.contains(INSTANCE_ID_KEY)) { String uuid = UUID.randomUUID().toString(); Timber.i("Did not find existing instance id. Saving a new one"); - SharedPreferences.Editor editor = instanceIdSharedPrefs.edit(); + SharedPreferences.Editor editor = mobileKeySharedPrefs.edit(); editor.putString(INSTANCE_ID_KEY, uuid); editor.apply(); } - instanceId = instanceIdSharedPrefs.getString(INSTANCE_ID_KEY, instanceId); + instanceId = mobileKeySharedPrefs.getString(INSTANCE_ID_KEY, instanceId); Timber.i("Using instance id: " + instanceId); this.fetcher = HttpFeatureFlagFetcher.init(application, config); 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 index 2fac93d3..0f1a8743 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java @@ -173,7 +173,6 @@ void unRegisterListener(String key, FeatureFlagChangeListener listener) { } void saveCurrentUserFlags(SharedPreferencesEntries sharedPreferencesEntries) { - sharedPreferencesEntries.clearAndSave(currentUserSharedPrefs); } @@ -289,7 +288,6 @@ static class SharedPreferencesEntries { } void clearAndSave(SharedPreferences sharedPreferences) { - SharedPreferences.Editor editor = sharedPreferences.edit(); editor.clear(); for (SharedPreferencesEntry entry : sharedPreferencesEntryList) { 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 a4b5eb37..e22af83f 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 @@ -59,6 +59,8 @@ class UserManager { private final ExecutorService executor; + private final String environment; + static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher, String environment) { return new UserManager(application, fetcher, environment); } @@ -73,6 +75,7 @@ static synchronized UserManager newInstance(Application application, FeatureFlag this.userLocalSharedPreferences = new UserLocalSharedPreferences(application); this.flagResponseSharedPreferences = new UserFlagResponseSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + "version"); this.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + "summaryevents"); + this.environment = environment; jsonParser = new Util.LazySingleton<>(new Util.Provider() { @Override From b595b6c5498c4fd69acaf82e8354f36698bb9dc5 Mon Sep 17 00:00:00 2001 From: torchhound Date: Sun, 7 Oct 2018 23:39:07 -0400 Subject: [PATCH 019/152] All tests pass, fixed migration strategy to conform to spec, fixed primaryInstance null when offline in init, fixed primaryEnvironmentName being added to secondaryMobileKeys --- .../src/main/java/com/launchdarkly/android/LDClient.java | 9 +++++++-- .../src/main/java/com/launchdarkly/android/LDConfig.java | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) 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 69efb024..aea3c531 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 @@ -94,7 +94,7 @@ public static synchronized Future init(@NonNull Application applicatio SettableFuture settableFuture = SettableFuture.create(); if (instances != null) { - Timber.w("LDClient.init() was called more than once! returning existing instance."); + Timber.w("LDClient.init() was called more than once! returning primary instance."); settableFuture.set(instances.get(LDConfig.primaryEnvironmentName)); return settableFuture; } @@ -122,6 +122,9 @@ public static synchronized Future init(@NonNull Application applicatio instance.sendEvent(new IdentifyEvent(user)); } + final LDClient storedPrimaryInstance = new LDClient(application, config); + storedPrimaryInstance.userManager.setCurrentUser(user); + instances.put(LDConfig.primaryEnvironmentName, storedPrimaryInstance); final LDClient primaryInstance = instances.get(LDConfig.primaryEnvironmentName); if (primaryInstance.isOffline() || !internetConnected) { @@ -259,6 +262,8 @@ else if(value instanceof String) editor.putString(key, ((String)value)); editor.apply(); } + + instanceIdSharedPrefs.edit().clear().apply(); } if (!mobileKeySharedPrefs.contains(INSTANCE_ID_KEY)) { @@ -270,7 +275,7 @@ else if(value instanceof String) } instanceId = mobileKeySharedPrefs.getString(INSTANCE_ID_KEY, instanceId); - Timber.i("Using instance id: " + instanceId); + Timber.i("Using instance id: %s", instanceId); this.fetcher = HttpFeatureFlagFetcher.newInstance(application, config, environmentName); this.userManager = UserManager.newInstance(application, fetcher); 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 8adba2e4..20f72a9b 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 @@ -460,7 +460,7 @@ public LDConfig build() { if (secondaryMobileKeys == null) { secondaryMobileKeys = new HashMap<>(); } - secondaryMobileKeys.put(primaryEnvironmentName, mobileKey); + //secondaryMobileKeys.put(primaryEnvironmentName, mobileKey); //TODO(jcieslik) primaryEnvironmentName cannot be in secondaryMobileKeys return new LDConfig( secondaryMobileKeys, From e8ac421841175b3b04cb4a1f2a9681ef7e59c2e3 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 9 Oct 2018 17:42:19 +0000 Subject: [PATCH 020/152] Update StreamUpdateProcessor construct to take an environment for the authorization header key. --- .../src/main/java/com/launchdarkly/android/LDClient.java | 6 +++--- .../com/launchdarkly/android/StreamUpdateProcessor.java | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) 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 aea3c531..044e7be4 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 @@ -48,8 +48,8 @@ public class LDClient implements LDClientInterface, Closeable { private static String instanceId = "UNKNOWN_ANDROID"; private static Map instances = null; - private static final long MAX_RETRY_TIME_MS = 3600000; // 1 hour - private static final long RETRY_TIME_MS = 1000; // 1 second + private static final long MAX_RETRY_TIME_MS = 3_600_000; // 1 hour + private static final long RETRY_TIME_MS = 1_000; // 1 second private final WeakReference application; private final LDConfig config; @@ -300,7 +300,7 @@ public void onBecameBackground() { foreground.addListener(foregroundListener); if (config.isStream()) { - this.updateProcessor = new StreamUpdateProcessor(config, userManager); + this.updateProcessor = new StreamUpdateProcessor(config, userManager, environmentName); } else { Timber.i("Streaming is disabled. Starting LaunchDarkly Client in polling mode"); this.updateProcessor = new PollingUpdateProcessor(application, userManager, config); 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 08127595..bf9cf453 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 @@ -31,7 +31,7 @@ class StreamUpdateProcessor implements UpdateProcessor { private static final String PATCH = "patch"; private static final String DELETE = "delete"; - private static final long MAX_RECONNECT_TIME_MS = 3600000; // 1 hour + private static final long MAX_RECONNECT_TIME_MS = 3_600_000; // 1 hour private EventSource es; private final LDConfig config; @@ -42,10 +42,12 @@ class StreamUpdateProcessor implements UpdateProcessor { private Debounce queue; private boolean connection401Error = false; private final ExecutorService executor; + private final String environmentName; - StreamUpdateProcessor(LDConfig config, UserManager userManager) { + StreamUpdateProcessor(LDConfig config, UserManager userManager, String environmentName) { this.config = config; this.userManager = userManager; + this.environmentName = environmentName; queue = new Debounce(); executor = new BackgroundThreadExecutor().newFixedThreadPool(2); @@ -58,7 +60,7 @@ public synchronized ListenableFuture start() { stop(); Timber.d("Starting."); Headers headers = new Headers.Builder() - .add("Authorization", config.getMobileKey()) + .add("Authorization", config.getMobileKeys().get(environmentName)) .add("User-Agent", LDConfig.USER_AGENT_HEADER_VALUE) .add("Accept", "text/event-stream") .build(); From 441fa934388921cb023e75e51b5ef2d4208a41c9 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 9 Oct 2018 17:48:21 +0000 Subject: [PATCH 021/152] Fix issue with LDConfig mobileKeys hashmap creation. --- .../main/java/com/launchdarkly/android/LDConfig.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 20f72a9b..455a906f 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 @@ -457,13 +457,18 @@ public LDConfig build() { PollingUpdater.backgroundPollingIntervalMillis = backgroundPollingIntervalMillis; + + HashMap mobileKeys; if (secondaryMobileKeys == null) { - secondaryMobileKeys = new HashMap<>(); + mobileKeys = new HashMap<>(); + } + else { + mobileKeys = new HashMap<>(secondaryMobileKeys); } - //secondaryMobileKeys.put(primaryEnvironmentName, mobileKey); //TODO(jcieslik) primaryEnvironmentName cannot be in secondaryMobileKeys + mobileKeys.put(primaryEnvironmentName, mobileKey); return new LDConfig( - secondaryMobileKeys, + mobileKeys, baseUri, eventsUri, streamUri, From f0b68e633b1a3e1ba1ddd37b7ef019a2784a3b07 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 10 Oct 2018 17:11:26 +0000 Subject: [PATCH 022/152] Combine futures so LDClient init future waits on all online instances of LDClient. --- .../com/launchdarkly/android/LDClient.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) 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 044e7be4..6c7ead62 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 @@ -23,7 +23,9 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; @@ -105,7 +107,6 @@ public static synchronized Future init(@NonNull Application applicatio boolean internetConnected = isInternetConnected(application); instances = new HashMap<>(); - // TODO(gavwhela) Handle futures merging Map> updateFutures = new HashMap<>(); for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { @@ -122,23 +123,27 @@ public static synchronized Future init(@NonNull Application applicatio instance.sendEvent(new IdentifyEvent(user)); } - final LDClient storedPrimaryInstance = new LDClient(application, config); - storedPrimaryInstance.userManager.setCurrentUser(user); - instances.put(LDConfig.primaryEnvironmentName, storedPrimaryInstance); final LDClient primaryInstance = instances.get(LDConfig.primaryEnvironmentName); - if (primaryInstance.isOffline() || !internetConnected) { + if (!internetConnected) { settableFuture.set(primaryInstance); return settableFuture; } - primaryInstance.sendEvent(new IdentifyEvent(user)); - ListenableFuture initFuture = updateFutures.get(LDConfig.primaryEnvironmentName); + ArrayList> online = new ArrayList<>(); + + for(Map.Entry> entry : updateFutures.entrySet()) { + if (!instances.get(entry.getKey()).isOffline()) { + online.add(entry.getValue()); + } + } + + ListenableFuture> allFuture = Futures.allAsList(online); // Transform initFuture so its result is the instance: - return Futures.transform(initFuture, new Function() { + return Futures.transform(allFuture, new Function, LDClient>() { @Override - public LDClient apply(Void input) { + public LDClient apply(List input) { return primaryInstance; } }, MoreExecutors.directExecutor()); From 1a039e128b6d3f242644b0d1e11108bbc3cfef8c Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 10 Oct 2018 21:31:45 +0000 Subject: [PATCH 023/152] Propagate IOException on closing instances to caller. --- .../src/main/java/com/launchdarkly/android/LDClient.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 6c7ead62..ba895a34 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 @@ -629,15 +629,17 @@ private void closeInternal() throws IOException { eventProcessor.close(); } - public static void closeInstances() { + public static void closeInstances() throws IOException { + IOException exception = null; for (LDClient client : instances.values()) { try { client.closeInternal(); } catch (IOException e) { - // TODO(gavwhela) handle IOException - e.printStackTrace(); + exception = e; } } + if (exception != null) + throw exception; } /** From baea3ffd3ce18ee22b819f17873bb172e86d322d Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 10 Oct 2018 22:27:55 +0000 Subject: [PATCH 024/152] Merge futures for identify call. --- .../com/launchdarkly/android/LDClient.java | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) 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 ba895a34..b2630498 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 @@ -132,7 +132,7 @@ public static synchronized Future init(@NonNull Application applicatio ArrayList> online = new ArrayList<>(); - for(Map.Entry> entry : updateFutures.entrySet()) { + for (Map.Entry> entry : updateFutures.entrySet()) { if (!instances.get(entry.getKey()).isOffline()) { online.add(entry.getValue()); } @@ -149,7 +149,7 @@ public LDClient apply(List input) { }, MoreExecutors.directExecutor()); } - public static Set getEnvironmentNames() throws LaunchDarklyException{ + public static Set getEnvironmentNames() throws LaunchDarklyException { if (instances == null) { Timber.e("LDClient.getEnvironmentNames() was called before init()!"); throw new LaunchDarklyException("LDClient.getEnvironmentNames() was called before init()!"); @@ -255,16 +255,16 @@ protected LDClient(final Application application, @NonNull final LDConfig config Object value = entry.getValue(); String key = entry.getKey(); - if(value instanceof Boolean) + if (value instanceof Boolean) editor.putBoolean(key, (Boolean) value); - else if(value instanceof Float) + else if (value instanceof Float) editor.putFloat(key, (Float) value); - else if(value instanceof Integer) + else if (value instanceof Integer) editor.putInt(key, (Integer) value); - else if(value instanceof Long) + else if (value instanceof Long) editor.putLong(key, (Long) value); - else if(value instanceof String) - editor.putString(key, ((String)value)); + else if (value instanceof String) + editor.putString(key, ((String) value)); editor.apply(); } @@ -358,10 +358,10 @@ public void track(String eventName) { */ @Override public synchronized Future identify(LDUser user) { - return identifyInstances(user); + return LDClient.identifyInstances(user); } - private synchronized Future identifyInternal(LDUser user) { + private synchronized ListenableFuture identifyInternal(LDUser user) { if (user == null) { return Futures.immediateFailedFuture(new LaunchDarklyException("User cannot be null")); } @@ -384,13 +384,23 @@ private synchronized Future identifyInternal(LDUser user) { return doneFuture; } - private synchronized Future identifyInstances(LDUser user) { - SettableFuture voidFuture = SettableFuture.create(); + private static synchronized Future identifyInstances(LDUser user) { + if (user == null) { + return Futures.immediateFailedFuture(new LaunchDarklyException("User cannot be null")); + } + + ArrayList> futures = new ArrayList<>(); + for (LDClient client : instances.values()) { - client.identifyInternal(user); + futures.add(client.identifyInternal(user)); } - voidFuture.set(null); - return voidFuture; //TODO(jcieslik) Null type checks for Future, that would be cleaner + + return Futures.transform(Futures.allAsList(futures), new Function, Void>() { + @Override + public Void apply(List input) { + return null; + } + }, MoreExecutors.directExecutor()); } /** @@ -658,7 +668,6 @@ public static void flushInstances() { for (LDClient client : instances.values()) { client.flushInternal(); } - } @Override From fc665cf39cefc79d34d7f28ce6a94fc8b49fe337 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 11 Oct 2018 01:18:04 +0000 Subject: [PATCH 025/152] Some changes from code review. --- .../main/java/com/launchdarkly/android/EventProcessor.java | 6 ++++-- .../src/main/java/com/launchdarkly/android/LDClient.java | 2 +- .../src/main/java/com/launchdarkly/android/LDConfig.java | 7 +++---- 3 files changed, 8 insertions(+), 7 deletions(-) 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 938dc011..6f7201e4 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 @@ -41,14 +41,16 @@ class EventProcessor implements Closeable { private final OkHttpClient client; private final Context context; private final LDConfig config; + private final String environmentName; private ScheduledExecutorService scheduler; private SummaryEvent summaryEvent = null; private final SummaryEventSharedPreferences summaryEventSharedPreferences; private long currentTimeMs = System.currentTimeMillis(); - EventProcessor(Context context, LDConfig config, SummaryEventSharedPreferences summaryEventSharedPreferences) { + EventProcessor(Context context, LDConfig config, SummaryEventSharedPreferences summaryEventSharedPreferences, String environmentName) { this.context = context; this.config = config; + this.environmentName = environmentName; this.queue = new ArrayBlockingQueue<>(config.getEventsCapacity()); this.consumer = new Consumer(config); this.summaryEventSharedPreferences = summaryEventSharedPreferences; @@ -138,7 +140,7 @@ public synchronized void flush() { private void postEvents(List events) { String content = config.getFilteredEventGson().toJson(events); - Request request = config.getRequestBuilder() + Request request = config.getRequestBuilderFor(environmentName) .url(config.getEventsUri().toString()) .post(RequestBody.create(JSON, content)) .addHeader("Content-Type", "application/json") 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 b2630498..f5401b58 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 @@ -310,7 +310,7 @@ public void onBecameBackground() { Timber.i("Streaming is disabled. Starting LaunchDarkly Client in polling mode"); this.updateProcessor = new PollingUpdateProcessor(application, userManager, config); } - eventProcessor = new EventProcessor(application, config, userManager.getSummaryEventSharedPreferences()); + eventProcessor = new EventProcessor(application, config, userManager.getSummaryEventSharedPreferences(), environmentName); throttler = new Throttler(new Runnable() { @Override 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 455a906f..ffc11568 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 @@ -107,8 +107,8 @@ public Request.Builder getRequestBuilder() { } public Request.Builder getRequestBuilderFor(String environment) { - if (environment == null || environment.equals(primaryEnvironmentName)) - return getRequestBuilder(); + if (environment == null) + throw new IllegalArgumentException("null is not a valid environment"); String key = mobileKeys.get(environment); if (key == null) @@ -240,7 +240,7 @@ public Builder setPrivateAttributeNames(Set privateAttributeNames) { */ public LDConfig.Builder setMobileKey(String mobileKey) { if (secondaryMobileKeys != null && secondaryMobileKeys.containsValue(mobileKey)) { - // Throw error about reuse of primary mobile key in secondary mobile keys + throw new IllegalArgumentException("The primary environment key cannot be in the secondary mobile keys."); } this.mobileKey = mobileKey; @@ -457,7 +457,6 @@ public LDConfig build() { PollingUpdater.backgroundPollingIntervalMillis = backgroundPollingIntervalMillis; - HashMap mobileKeys; if (secondaryMobileKeys == null) { mobileKeys = new HashMap<>(); From 783375dc68ca435c3f8c9c0ec1f181ce5a70d857 Mon Sep 17 00:00:00 2001 From: torchhound Date: Thu, 11 Oct 2018 00:49:02 -0400 Subject: [PATCH 026/152] Removed static from instanceId and now old SharedPreferences will only cleared once all environments have a copy in LDClient --- .../java/com/launchdarkly/android/LDClient.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 f5401b58..ce1337b0 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 @@ -46,8 +46,10 @@ public class LDClient implements LDClientInterface, Closeable { private static final String INSTANCE_ID_KEY = "instanceId"; + private static final String INSTANCE_CLEAR_KEY = "clear"; + private static int instanceClearCounter = 0; // Upon client init will get set to a Unique id per installation used when creating anonymous users - private static String instanceId = "UNKNOWN_ANDROID"; + private String instanceId = "UNKNOWN_ANDROID"; private static Map instances = null; private static final long MAX_RETRY_TIME_MS = 3_600_000; // 1 hour @@ -248,7 +250,7 @@ protected LDClient(final Application application, @NonNull final LDConfig config SharedPreferences mobileKeySharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + config.getMobileKeys().get(environmentName), Context.MODE_PRIVATE); - if (instanceIdSharedPrefs != null) { + if (!instanceIdSharedPrefs.contains(INSTANCE_CLEAR_KEY)) { SharedPreferences.Editor editor = mobileKeySharedPrefs.edit(); for (Map.Entry entry : instanceIdSharedPrefs.getAll().entrySet()) { @@ -268,7 +270,13 @@ else if (value instanceof String) editor.apply(); } - instanceIdSharedPrefs.edit().clear().apply(); + if (instanceClearCounter > config.getMobileKeys().size()) { //SharedPreferences will only be cleared if all environments have a copy + instanceIdSharedPrefs.edit().clear().commit(); + instanceIdSharedPrefs.edit().putString(INSTANCE_CLEAR_KEY, INSTANCE_CLEAR_KEY).apply(); + } else { + instanceClearCounter++; + } + } if (!mobileKeySharedPrefs.contains(INSTANCE_ID_KEY)) { @@ -772,7 +780,7 @@ public boolean isDisableBackgroundPolling() { return config.isDisableBackgroundPolling(); } - static String getInstanceId() { + String getInstanceId() { return instanceId; } From f82c03f95ebb8ba9fd522b5f50a4c44dc9ef4821 Mon Sep 17 00:00:00 2001 From: torchhound Date: Thu, 11 Oct 2018 13:19:29 -0400 Subject: [PATCH 027/152] Fixed instanceId --- .../src/main/java/com/launchdarkly/android/LDClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ce1337b0..e6db5ace 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 @@ -49,7 +49,7 @@ public class LDClient implements LDClientInterface, Closeable { private static final String INSTANCE_CLEAR_KEY = "clear"; private static int instanceClearCounter = 0; // Upon client init will get set to a Unique id per installation used when creating anonymous users - private String instanceId = "UNKNOWN_ANDROID"; + private static String instanceId = "UNKNOWN_ANDROID"; private static Map instances = null; private static final long MAX_RETRY_TIME_MS = 3_600_000; // 1 hour @@ -780,7 +780,7 @@ public boolean isDisableBackgroundPolling() { return config.isDisableBackgroundPolling(); } - String getInstanceId() { + static String getInstanceId() { return instanceId; } From be1dd1ca436db03f455705dbefe5949fe203b2c8 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 17 Oct 2018 15:46:22 +0000 Subject: [PATCH 028/152] Updates from PR review. --- .../src/main/java/com/launchdarkly/android/LDClient.java | 8 ++++---- .../src/main/java/com/launchdarkly/android/LDConfig.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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 e6db5ace..6e193358 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 @@ -647,7 +647,7 @@ private void closeInternal() throws IOException { eventProcessor.close(); } - public static void closeInstances() throws IOException { + private static void closeInstances() throws IOException { IOException exception = null; for (LDClient client : instances.values()) { try { @@ -672,7 +672,7 @@ private void flushInternal() { eventProcessor.flush(); } - public static void flushInstances() { + private static void flushInstances() { for (LDClient client : instances.values()) { client.flushInternal(); } @@ -711,7 +711,7 @@ private synchronized void setOfflineInternal() { eventProcessor.stop(); } - public synchronized static void setInstancesOffline() { + private synchronized static void setInstancesOffline() { for (LDClient client : instances.values()) { client.setOfflineInternal(); } @@ -878,7 +878,7 @@ public SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return userManager.getSummaryEventSharedPreferences(); } - public UserManager getUserManager() { + UserManager getUserManager() { return userManager; } } 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 ffc11568..a399cf81 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 @@ -265,7 +265,7 @@ public LDConfig.Builder setSecondaryMobileKeys(Map secondaryMobi } Set secondaryKeys = new HashSet<>(unmodifiable.values()); if (mobileKey != null && secondaryKeys.contains(mobileKey)) { - throw new IllegalArgumentException("The primary environment name is not a valid key."); + throw new IllegalArgumentException("The primary environment key cannot be in the secondary mobile keys."); } if (unmodifiable.values().size() != secondaryKeys.size()) { throw new IllegalArgumentException("A key can only be used once."); From a8d4b5342d55d113389eaca61644116a8ac2aa3f Mon Sep 17 00:00:00 2001 From: jamesthacker Date: Tue, 6 Nov 2018 09:46:00 -0500 Subject: [PATCH 029/152] Added version and flagVersion, if available --- .../android/response/UserFlagResponse.java | 4 ---- .../interpreter/PingFlagResponseInterpreter.java | 11 ++++++++++- 2 files changed, 10 insertions(+), 5 deletions(-) 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 index d05d8872..ece3a93d 100644 --- 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 @@ -50,10 +50,6 @@ public UserFlagResponse(String key, JsonElement value, int version, int flagVers this(key, value, version, flagVersion, null, null, null); } - public UserFlagResponse(String key, JsonElement value, Integer variation, Boolean trackEvents, Long debugEventsUntilDate) { - this(key, value, -1, -1, variation, trackEvents, debugEventsUntilDate); - } - @NonNull @Override public String getKey() { 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 index badedaff..25506246 100644 --- 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 @@ -35,7 +35,16 @@ public List apply(@Nullable JsonObject input) { Boolean trackEvents = getTrackEvents(asJsonObject); Long debugEventsUntilDate = getDebugEventsUntilDate(asJsonObject); - flagResponseList.add(new UserFlagResponse(key, asJsonObject.get("value"), variation, trackEvents, debugEventsUntilDate)); + + 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)); } From f2c456ebaffc3771f7cf1277843520a9bdf7e4cc Mon Sep 17 00:00:00 2001 From: torchhound Date: Wed, 12 Dec 2018 13:31:52 -0500 Subject: [PATCH 030/152] refactor(LDClient, LDConfig): changes for PR --- example/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + example/gradlew | 172 ++++++++++++++++++ example/gradlew.bat | 84 +++++++++ example/local.properties | 8 + .../com/launchdarkly/android/LDClient.java | 68 +++---- .../com/launchdarkly/android/LDConfig.java | 10 +- 7 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 example/gradle/wrapper/gradle-wrapper.jar create mode 100644 example/gradle/wrapper/gradle-wrapper.properties create mode 100644 example/gradlew create mode 100644 example/gradlew.bat create mode 100644 example/local.properties diff --git a/example/gradle/wrapper/gradle-wrapper.jar b/example/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f6b961fd5a86aa5fbfe90f707c3138408be7c718 GIT binary patch literal 54329 zcmagFV|ZrKvM!pAZQHhO+qP}9lTNj?q^^Y^VFp)SH8qbSJ)2BQ2giqr}t zFG7D6)c?v~^Z#E_K}1nTQbJ9gQ9<%vVRAxVj)8FwL5_iTdUB>&m3fhE=kRWl;g`&m z!W5kh{WsV%fO*%je&j+Lv4xxK~zsEYQls$Q-p&dwID|A)!7uWtJF-=Tm1{V@#x*+kUI$=%KUuf2ka zjiZ{oiL1MXE2EjciJM!jrjFNwCh`~hL>iemrqwqnX?T*MX;U>>8yRcZb{Oy+VKZos zLiFKYPw=LcaaQt8tj=eoo3-@bG_342HQ%?jpgAE?KCLEHC+DmjxAfJ%Og^$dpC8Xw zAcp-)tfJm}BPNq_+6m4gBgBm3+CvmL>4|$2N$^Bz7W(}fz1?U-u;nE`+9`KCLuqg} zwNstNM!J4Uw|78&Y9~9>MLf56to!@qGkJw5Thx%zkzj%Ek9Nn1QA@8NBXbwyWC>9H z#EPwjMNYPigE>*Ofz)HfTF&%PFj$U6mCe-AFw$U%-L?~-+nSXHHKkdgC5KJRTF}`G zE_HNdrE}S0zf4j{r_f-V2imSqW?}3w-4=f@o@-q+cZgaAbZ((hn))@|eWWhcT2pLpTpL!;_5*vM=sRL8 zqU##{U#lJKuyqW^X$ETU5ETeEVzhU|1m1750#f}38_5N9)B_2|v@1hUu=Kt7-@dhA zq_`OMgW01n`%1dB*}C)qxC8q;?zPeF_r;>}%JYmlER_1CUbKa07+=TV45~symC*g8 zW-8(gag#cAOuM0B1xG8eTp5HGVLE}+gYTmK=`XVVV*U!>H`~j4+ROIQ+NkN$LY>h4 zqpwdeE_@AX@PL};e5vTn`Ro(EjHVf$;^oiA%@IBQq>R7_D>m2D4OwwEepkg}R_k*M zM-o;+P27087eb+%*+6vWFCo9UEGw>t&WI17Pe7QVuoAoGHdJ(TEQNlJOqnjZ8adCb zI`}op16D@v7UOEo%8E-~m?c8FL1utPYlg@m$q@q7%mQ4?OK1h%ODjTjFvqd!C z-PI?8qX8{a@6d&Lb_X+hKxCImb*3GFemm?W_du5_&EqRq!+H?5#xiX#w$eLti-?E$;Dhu`{R(o>LzM4CjO>ICf z&DMfES#FW7npnbcuqREgjPQM#gs6h>`av_oEWwOJZ2i2|D|0~pYd#WazE2Bbsa}X@ zu;(9fi~%!VcjK6)?_wMAW-YXJAR{QHxrD5g(ou9mR6LPSA4BRG1QSZT6A?kelP_g- zH(JQjLc!`H4N=oLw=f3{+WmPA*s8QEeEUf6Vg}@!xwnsnR0bl~^2GSa5vb!Yl&4!> zWb|KQUsC$lT=3A|7vM9+d;mq=@L%uWKwXiO9}a~gP4s_4Yohc!fKEgV7WbVo>2ITbE*i`a|V!^p@~^<={#?Gz57 zyPWeM2@p>D*FW#W5Q`1`#5NW62XduP1XNO(bhg&cX`-LYZa|m-**bu|>}S;3)eP8_ zpNTnTfm8 ze+7wDH3KJ95p)5tlwk`S7mbD`SqHnYD*6`;gpp8VdHDz%RR_~I_Ar>5)vE-Pgu7^Y z|9Px+>pi3!DV%E%4N;ii0U3VBd2ZJNUY1YC^-e+{DYq+l@cGtmu(H#Oh%ibUBOd?C z{y5jW3v=0eV0r@qMLgv1JjZC|cZ9l9Q)k1lLgm))UR@#FrJd>w^`+iy$c9F@ic-|q zVHe@S2UAnc5VY_U4253QJxm&Ip!XKP8WNcnx9^cQ;KH6PlW8%pSihSH2(@{2m_o+m zr((MvBja2ctg0d0&U5XTD;5?d?h%JcRJp{_1BQW1xu&BrA3(a4Fh9hon-ly$pyeHq zG&;6q?m%NJ36K1Sq_=fdP(4f{Hop;_G_(i?sPzvB zDM}>*(uOsY0I1j^{$yn3#U(;B*g4cy$-1DTOkh3P!LQ;lJlP%jY8}Nya=h8$XD~%Y zbV&HJ%eCD9nui-0cw!+n`V~p6VCRqh5fRX z8`GbdZ@73r7~myQLBW%db;+BI?c-a>Y)m-FW~M=1^|<21_Sh9RT3iGbO{o-hpN%d6 z7%++#WekoBOP^d0$$|5npPe>u3PLvX_gjH2x(?{&z{jJ2tAOWTznPxv-pAv<*V7r$ z6&glt>7CAClWz6FEi3bToz-soY^{ScrjwVPV51=>n->c(NJngMj6TyHty`bfkF1hc zkJS%A@cL~QV0-aK4>Id!9dh7>0IV;1J9(myDO+gv76L3NLMUm9XyPauvNu$S<)-|F zZS}(kK_WnB)Cl`U?jsdYfAV4nrgzIF@+%1U8$poW&h^c6>kCx3;||fS1_7JvQT~CV zQ8Js+!p)3oW>Df(-}uqC`Tcd%E7GdJ0p}kYj5j8NKMp(KUs9u7?jQ94C)}0rba($~ zqyBx$(1ae^HEDG`Zc@-rXk1cqc7v0wibOR4qpgRDt#>-*8N3P;uKV0CgJE2SP>#8h z=+;i_CGlv+B^+$5a}SicVaSeaNn29K`C&=}`=#Nj&WJP9Xhz4mVa<+yP6hkrq1vo= z1rX4qg8dc4pmEvq%NAkpMK>mf2g?tg_1k2%v}<3`$6~Wlq@ItJ*PhHPoEh1Yi>v57 z4k0JMO)*=S`tKvR5gb-(VTEo>5Y>DZJZzgR+j6{Y`kd|jCVrg!>2hVjz({kZR z`dLlKhoqT!aI8=S+fVp(5*Dn6RrbpyO~0+?fy;bm$0jmTN|t5i6rxqr4=O}dY+ROd zo9Et|x}!u*xi~>-y>!M^+f&jc;IAsGiM_^}+4|pHRn{LThFFpD{bZ|TA*wcGm}XV^ zr*C6~@^5X-*R%FrHIgo-hJTBcyQ|3QEj+cSqp#>&t`ZzB?cXM6S(lRQw$I2?m5=wd z78ki`R?%;o%VUhXH?Z#(uwAn9$m`npJ=cA+lHGk@T7qq_M6Zoy1Lm9E0UUysN)I_x zW__OAqvku^>`J&CB=ie@yNWsaFmem}#L3T(x?a`oZ+$;3O-icj2(5z72Hnj=9Z0w% z<2#q-R=>hig*(t0^v)eGq2DHC%GymE-_j1WwBVGoU=GORGjtaqr0BNigOCqyt;O(S zKG+DoBsZU~okF<7ahjS}bzwXxbAxFfQAk&O@>LsZMsZ`?N?|CDWM(vOm%B3CBPC3o z%2t@%H$fwur}SSnckUm0-k)mOtht`?nwsDz=2#v=RBPGg39i#%odKq{K^;bTD!6A9 zskz$}t)sU^=a#jLZP@I=bPo?f-L}wpMs{Tc!m7-bi!Ldqj3EA~V;4(dltJmTXqH0r z%HAWKGutEc9vOo3P6Q;JdC^YTnby->VZ6&X8f{obffZ??1(cm&L2h7q)*w**+sE6dG*;(H|_Q!WxU{g)CeoT z(KY&bv!Usc|m+Fqfmk;h&RNF|LWuNZ!+DdX*L=s-=_iH=@i` z?Z+Okq^cFO4}_n|G*!)Wl_i%qiMBaH8(WuXtgI7EO=M>=i_+;MDjf3aY~6S9w0K zUuDO7O5Ta6+k40~xh~)D{=L&?Y0?c$s9cw*Ufe18)zzk%#ZY>Tr^|e%8KPb0ht`b( zuP@8#Ox@nQIqz9}AbW0RzE`Cf>39bOWz5N3qzS}ocxI=o$W|(nD~@EhW13Rj5nAp; zu2obEJa=kGC*#3=MkdkWy_%RKcN=?g$7!AZ8vBYKr$ePY(8aIQ&yRPlQ=mudv#q$q z4%WzAx=B{i)UdLFx4os?rZp6poShD7Vc&mSD@RdBJ=_m^&OlkEE1DFU@csgKcBifJ zz4N7+XEJhYzzO=86 z#%eBQZ$Nsf2+X0XPHUNmg#(sNt^NW1Y0|M(${e<0kW6f2q5M!2YE|hSEQ*X-%qo(V zHaFwyGZ0on=I{=fhe<=zo{=Og-_(to3?cvL4m6PymtNsdDINsBh8m>a%!5o3s(en) z=1I z6O+YNertC|OFNqd6P=$gMyvmfa`w~p9*gKDESFqNBy(~Zw3TFDYh}$iudn)9HxPBi zdokK@o~nu?%imcURr5Y~?6oo_JBe}t|pU5qjai|#JDyG=i^V~7+a{dEnO<(y>ahND#_X_fcEBNiZ)uc&%1HVtx8Ts z*H_Btvx^IhkfOB#{szN*n6;y05A>3eARDXslaE>tnLa>+`V&cgho?ED+&vv5KJszf zG4@G;7i;4_bVvZ>!mli3j7~tPgybF5|J6=Lt`u$D%X0l}#iY9nOXH@(%FFJLtzb%p zzHfABnSs;v-9(&nzbZytLiqqDIWzn>JQDk#JULcE5CyPq_m#4QV!}3421haQ+LcfO*>r;rg6K|r#5Sh|y@h1ao%Cl)t*u`4 zMTP!deC?aL7uTxm5^nUv#q2vS-5QbBKP|drbDXS%erB>fYM84Kpk^au99-BQBZR z7CDynflrIAi&ahza+kUryju5LR_}-Z27g)jqOc(!Lx9y)e z{cYc&_r947s9pteaa4}dc|!$$N9+M38sUr7h(%@Ehq`4HJtTpA>B8CLNO__@%(F5d z`SmX5jbux6i#qc}xOhumzbAELh*Mfr2SW99=WNOZRZgoCU4A2|4i|ZVFQt6qEhH#B zK_9G;&h*LO6tB`5dXRSBF0hq0tk{2q__aCKXYkP#9n^)@cq}`&Lo)1KM{W+>5mSed zKp~=}$p7>~nK@va`vN{mYzWN1(tE=u2BZhga5(VtPKk(*TvE&zmn5vSbjo zZLVobTl%;t@6;4SsZ>5+U-XEGUZGG;+~|V(pE&qqrp_f~{_1h@5ZrNETqe{bt9ioZ z#Qn~gWCH!t#Ha^n&fT2?{`}D@s4?9kXj;E;lWV9Zw8_4yM0Qg-6YSsKgvQ*fF{#Pq z{=(nyV>#*`RloBVCs;Lp*R1PBIQOY=EK4CQa*BD0MsYcg=opP?8;xYQDSAJBeJpw5 zPBc_Ft9?;<0?pBhCmOtWU*pN*;CkjJ_}qVic`}V@$TwFi15!mF1*m2wVX+>5p%(+R zQ~JUW*zWkalde{90@2v+oVlkxOZFihE&ZJ){c?hX3L2@R7jk*xjYtHi=}qb+4B(XJ z$gYcNudR~4Kz_WRq8eS((>ALWCO)&R-MXE+YxDn9V#X{_H@j616<|P(8h(7z?q*r+ zmpqR#7+g$cT@e&(%_|ipI&A%9+47%30TLY(yuf&*knx1wNx|%*H^;YB%ftt%5>QM= z^i;*6_KTSRzQm%qz*>cK&EISvF^ovbS4|R%)zKhTH_2K>jP3mBGn5{95&G9^a#4|K zv+!>fIsR8z{^x4)FIr*cYT@Q4Z{y}};rLHL+atCgHbfX*;+k&37DIgENn&=k(*lKD zG;uL-KAdLn*JQ?@r6Q!0V$xXP=J2i~;_+i3|F;_En;oAMG|I-RX#FwnmU&G}w`7R{ z788CrR-g1DW4h_`&$Z`ctN~{A)Hv_-Bl!%+pfif8wN32rMD zJDs$eVWBYQx1&2sCdB0!vU5~uf)=vy*{}t{2VBpcz<+~h0wb7F3?V^44*&83Z2#F` z32!rd4>uc63rQP$3lTH3zb-47IGR}f)8kZ4JvX#toIpXH`L%NnPDE~$QI1)0)|HS4 zVcITo$$oWWwCN@E-5h>N?Hua!N9CYb6f8vTFd>h3q5Jg-lCI6y%vu{Z_Uf z$MU{{^o~;nD_@m2|E{J)q;|BK7rx%`m``+OqZAqAVj-Dy+pD4-S3xK?($>wn5bi90CFAQ+ACd;&m6DQB8_o zjAq^=eUYc1o{#+p+ zn;K<)Pn*4u742P!;H^E3^Qu%2dM{2slouc$AN_3V^M7H_KY3H)#n7qd5_p~Za7zAj|s9{l)RdbV9e||_67`#Tu*c<8!I=zb@ z(MSvQ9;Wrkq6d)!9afh+G`!f$Ip!F<4ADdc*OY-y7BZMsau%y?EN6*hW4mOF%Q~bw z2==Z3^~?q<1GTeS>xGN-?CHZ7a#M4kDL zQxQr~1ZMzCSKFK5+32C%+C1kE#(2L=15AR!er7GKbp?Xd1qkkGipx5Q~FI-6zt< z*PTpeVI)Ngnnyaz5noIIgNZtb4bQdKG{Bs~&tf)?nM$a;7>r36djllw%hQxeCXeW^ z(i6@TEIuxD<2ulwLTt|&gZP%Ei+l!(%p5Yij6U(H#HMkqM8U$@OKB|5@vUiuY^d6X zW}fP3;Kps6051OEO(|JzmVU6SX(8q>*yf*x5QoxDK={PH^F?!VCzES_Qs>()_y|jg6LJlJWp;L zKM*g5DK7>W_*uv}{0WUB0>MHZ#oJZmO!b3MjEc}VhsLD~;E-qNNd?x7Q6~v zR=0$u>Zc2Xr}>x_5$-s#l!oz6I>W?lw;m9Ae{Tf9eMX;TI-Wf_mZ6sVrMnY#F}cDd z%CV*}fDsXUF7Vbw>PuDaGhu631+3|{xp<@Kl|%WxU+vuLlcrklMC!Aq+7n~I3cmQ! z`e3cA!XUEGdEPSu``&lZEKD1IKO(-VGvcnSc153m(i!8ohi`)N2n>U_BemYJ`uY>8B*Epj!oXRLV}XK}>D*^DHQ7?NY*&LJ9VSo`Ogi9J zGa;clWI8vIQqkngv2>xKd91K>?0`Sw;E&TMg&6dcd20|FcTsnUT7Yn{oI5V4@Ow~m zz#k~8TM!A9L7T!|colrC0P2WKZW7PNj_X4MfESbt<-soq*0LzShZ}fyUx!(xIIDwx zRHt^_GAWe0-Vm~bDZ(}XG%E+`XhKpPlMBo*5q_z$BGxYef8O!ToS8aT8pmjbPq)nV z%x*PF5ZuSHRJqJ!`5<4xC*xb2vC?7u1iljB_*iUGl6+yPyjn?F?GOF2_KW&gOkJ?w z3e^qc-te;zez`H$rsUCE0<@7PKGW?7sT1SPYWId|FJ8H`uEdNu4YJjre`8F*D}6Wh z|FQ`xf7yiphHIAkU&OYCn}w^ilY@o4larl?^M7&8YI;hzBIsX|i3UrLsx{QDKwCX< zy;a>yjfJ6!sz`NcVi+a!Fqk^VE^{6G53L?@Tif|j!3QZ0fk9QeUq8CWI;OmO-Hs+F zuZ4sHLA3{}LR2Qlyo+{d@?;`tpp6YB^BMoJt?&MHFY!JQwoa0nTSD+#Ku^4b{5SZVFwU9<~APYbaLO zu~Z)nS#dxI-5lmS-Bnw!(u15by(80LlC@|ynj{TzW)XcspC*}z0~8VRZq>#Z49G`I zgl|C#H&=}n-ajxfo{=pxPV(L*7g}gHET9b*s=cGV7VFa<;Htgjk>KyW@S!|z`lR1( zGSYkEl&@-bZ*d2WQ~hw3NpP=YNHF^XC{TMG$Gn+{b6pZn+5=<()>C!N^jncl0w6BJ zdHdnmSEGK5BlMeZD!v4t5m7ct7{k~$1Ie3GLFoHjAH*b?++s<|=yTF+^I&jT#zuMx z)MLhU+;LFk8bse|_{j+d*a=&cm2}M?*arjBPnfPgLwv)86D$6L zLJ0wPul7IenMvVAK$z^q5<^!)7aI|<&GGEbOr=E;UmGOIa}yO~EIr5xWU_(ol$&fa zR5E(2vB?S3EvJglTXdU#@qfDbCYs#82Yo^aZN6`{Ex#M)easBTe_J8utXu(fY1j|R z9o(sQbj$bKU{IjyhosYahY{63>}$9_+hWxB3j}VQkJ@2$D@vpeRSldU?&7I;qd2MF zSYmJ>zA(@N_iK}m*AMPIJG#Y&1KR)6`LJ83qg~`Do3v^B0>fU&wUx(qefuTgzFED{sJ65!iw{F2}1fQ3= ziFIP{kezQxmlx-!yo+sC4PEtG#K=5VM9YIN0z9~c4XTX?*4e@m;hFM!zVo>A`#566 z>f&3g94lJ{r)QJ5m7Xe3SLau_lOpL;A($wsjHR`;xTXgIiZ#o&vt~ zGR6KdU$FFbLfZCC3AEu$b`tj!9XgOGLSV=QPIYW zjI!hSP#?8pn0@ezuenOzoka8!8~jXTbiJ6+ZuItsWW03uzASFyn*zV2kIgPFR$Yzm zE<$cZlF>R8?Nr2_i?KiripBc+TGgJvG@vRTY2o?(_Di}D30!k&CT`>+7ry2!!iC*X z<@=U0_C#16=PN7bB39w+zPwDOHX}h20Ap);dx}kjXX0-QkRk=cr};GYsjSvyLZa-t zzHONWddi*)RDUH@RTAsGB_#&O+QJaaL+H<<9LLSE+nB@eGF1fALwjVOl8X_sdOYme z0lk!X=S(@25=TZHR7LlPp}fY~yNeThMIjD}pd9+q=j<_inh0$>mIzWVY+Z9p<{D^#0Xk+b_@eNSiR8;KzSZ#7lUsk~NGMcB8C2c=m2l5paHPq`q{S(kdA7Z1a zyfk2Y;w?^t`?@yC5Pz9&pzo}Hc#}mLgDmhKV|PJ3lKOY(Km@Fi2AV~CuET*YfUi}u zfInZnqDX(<#vaS<^fszuR=l)AbqG{}9{rnyx?PbZz3Pyu!eSJK`uwkJU!ORQXy4x83r!PNgOyD33}}L=>xX_93l6njNTuqL8J{l%*3FVn3MG4&Fv*`lBXZ z?=;kn6HTT^#SrPX-N)4EZiIZI!0ByXTWy;;J-Tht{jq1mjh`DSy7yGjHxIaY%*sTx zuy9#9CqE#qi>1misx=KRWm=qx4rk|}vd+LMY3M`ow8)}m$3Ggv&)Ri*ON+}<^P%T5 z_7JPVPfdM=Pv-oH<tecoE}(0O7|YZc*d8`Uv_M*3Rzv7$yZnJE6N_W=AQ3_BgU_TjA_T?a)U1csCmJ&YqMp-lJe`y6>N zt++Bi;ZMOD%%1c&-Q;bKsYg!SmS^#J@8UFY|G3!rtyaTFb!5@e(@l?1t(87ln8rG? z--$1)YC~vWnXiW3GXm`FNSyzu!m$qT=Eldf$sMl#PEfGmzQs^oUd=GIQfj(X=}dw+ zT*oa0*oS%@cLgvB&PKIQ=Ok?>x#c#dC#sQifgMwtAG^l3D9nIg(Zqi;D%807TtUUCL3_;kjyte#cAg?S%e4S2W>9^A(uy8Ss0Tc++ZTjJw1 z&Em2g!3lo@LlDyri(P^I8BPpn$RE7n*q9Q-c^>rfOMM6Pd5671I=ZBjAvpj8oIi$! zl0exNl(>NIiQpX~FRS9UgK|0l#s@#)p4?^?XAz}Gjb1?4Qe4?j&cL$C8u}n)?A@YC zfmbSM`Hl5pQFwv$CQBF=_$Sq zxsV?BHI5bGZTk?B6B&KLdIN-40S426X3j_|ceLla*M3}3gx3(_7MVY1++4mzhH#7# zD>2gTHy*%i$~}mqc#gK83288SKp@y3wz1L_e8fF$Rb}ex+`(h)j}%~Ld^3DUZkgez zOUNy^%>>HHE|-y$V@B}-M|_{h!vXpk01xaD%{l{oQ|~+^>rR*rv9iQen5t?{BHg|% zR`;S|KtUb!X<22RTBA4AAUM6#M?=w5VY-hEV)b`!y1^mPNEoy2K)a>OyA?Q~Q*&(O zRzQI~y_W=IPi?-OJX*&&8dvY0zWM2%yXdFI!D-n@6FsG)pEYdJbuA`g4yy;qrgR?G z8Mj7gv1oiWq)+_$GqqQ$(ZM@#|0j7})=#$S&hZwdoijFI4aCFLVI3tMH5fLreZ;KD zqA`)0l~D2tuIBYOy+LGw&hJ5OyE+@cnZ0L5+;yo2pIMdt@4$r^5Y!x7nHs{@>|W(MzJjATyWGNwZ^4j+EPU0RpAl-oTM@u{lx*i0^yyWPfHt6QwPvYpk9xFMWfBFt!+Gu6TlAmr zeQ#PX71vzN*_-xh&__N`IXv6`>CgV#eA_%e@7wjgkj8jlKzO~Ic6g$cT`^W{R{606 zCDP~+NVZ6DMO$jhL~#+!g*$T!XW63#(ngDn#Qwy71yj^gazS{e;3jGRM0HedGD@pt z?(ln3pCUA(ekqAvvnKy0G@?-|-dh=eS%4Civ&c}s%wF@0K5Bltaq^2Os1n6Z3%?-Q zAlC4goQ&vK6TpgtzkHVt*1!tBYt-`|5HLV1V7*#45Vb+GACuU+QB&hZ=N_flPy0TY zR^HIrdskB#<$aU;HY(K{a3(OQa$0<9qH(oa)lg@Uf>M5g2W0U5 zk!JSlhrw8quBx9A>RJ6}=;W&wt@2E$7J=9SVHsdC?K(L(KACb#z)@C$xXD8^!7|uv zZh$6fkq)aoD}^79VqdJ!Nz-8$IrU(_-&^cHBI;4 z^$B+1aPe|LG)C55LjP;jab{dTf$0~xbXS9!!QdcmDYLbL^jvxu2y*qnx2%jbL%rB z{aP85qBJe#(&O~Prk%IJARcdEypZ)vah%ZZ%;Zk{eW(U)Bx7VlzgOi8)x z`rh4l`@l_Ada7z&yUK>ZF;i6YLGwI*Sg#Fk#Qr0Jg&VLax(nNN$u-XJ5=MsP3|(lEdIOJ7|(x3iY;ea)5#BW*mDV%^=8qOeYO&gIdJVuLLN3cFaN=xZtFB=b zH{l)PZl_j^u+qx@89}gAQW7ofb+k)QwX=aegihossZq*+@PlCpb$rpp>Cbk9UJO<~ zDjlXQ_Ig#W0zdD3&*ei(FwlN#3b%FSR%&M^ywF@Fr>d~do@-kIS$e%wkIVfJ|Ohh=zc zF&Rnic^|>@R%v?@jO}a9;nY3Qrg_!xC=ZWUcYiA5R+|2nsM*$+c$TOs6pm!}Z}dfM zGeBhMGWw3$6KZXav^>YNA=r6Es>p<6HRYcZY)z{>yasbC81A*G-le8~QoV;rtKnkx z;+os8BvEe?0A6W*a#dOudsv3aWs?d% z0oNngyVMjavLjtjiG`!007#?62ClTqqU$@kIY`=x^$2e>iqIy1>o|@Tw@)P)B8_1$r#6>DB_5 zmaOaoE~^9TolgDgooKFuEFB#klSF%9-~d2~_|kQ0Y{Ek=HH5yq9s zDq#1S551c`kSiWPZbweN^A4kWiP#Qg6er1}HcKv{fxb1*BULboD0fwfaNM_<55>qM zETZ8TJDO4V)=aPp_eQjX%||Ud<>wkIzvDlpNjqW>I}W!-j7M^TNe5JIFh#-}zAV!$ICOju8Kx)N z0vLtzDdy*rQN!7r>Xz7rLw8J-(GzQlYYVH$WK#F`i_i^qVlzTNAh>gBWKV@XC$T-` z3|kj#iCquDhiO7NKum07i|<-NuVsX}Q}mIP$jBJDMfUiaWR3c|F_kWBMw0_Sr|6h4 zk`_r5=0&rCR^*tOy$A8K;@|NqwncjZ>Y-75vlpxq%Cl3EgH`}^^~=u zoll6xxY@a>0f%Ddpi;=cY}fyG!K2N-dEyXXmUP5u){4VnyS^T4?pjN@Ot4zjL(Puw z_U#wMH2Z#8Pts{olG5Dy0tZj;N@;fHheu>YKYQU=4Bk|wcD9MbA`3O4bj$hNRHwzb zSLcG0SLV%zywdbuwl(^E_!@&)TdXge4O{MRWk2RKOt@!8E{$BU-AH(@4{gxs=YAz9LIob|Hzto0}9cWoz6Tp2x0&xi#$ zHh$dwO&UCR1Ob2w00-2eG7d4=cN(Y>0R#$q8?||q@iTi+7-w-xR%uMr&StFIthC<# zvK(aPduwuNB}oJUV8+Zl)%cnfsHI%4`;x6XW^UF^e4s3Z@S<&EV8?56Wya;HNs0E> z`$0dgRdiUz9RO9Au3RmYq>K#G=X%*_dUbSJHP`lSfBaN8t-~@F>)BL1RT*9I851A3 z<-+Gb#_QRX>~av#Ni<#zLswtu-c6{jGHR>wflhKLzC4P@b%8&~u)fosoNjk4r#GvC zlU#UU9&0Hv;d%g72Wq?Ym<&&vtA3AB##L}=ZjiTR4hh7J)e>ei} zt*u+>h%MwN`%3}b4wYpV=QwbY!jwfIj#{me)TDOG`?tI!%l=AwL2G@9I~}?_dA5g6 zCKgK(;6Q0&P&K21Tx~k=o6jwV{dI_G+Ba*Zts|Tl6q1zeC?iYJTb{hel*x>^wb|2RkHkU$!+S4OU4ZOKPZjV>9OVsqNnv5jK8TRAE$A&^yRwK zj-MJ3Pl?)KA~fq#*K~W0l4$0=8GRx^9+?w z!QT8*-)w|S^B0)ZeY5gZPI2G(QtQf?DjuK(s^$rMA!C%P22vynZY4SuOE=wX2f8$R z)A}mzJi4WJnZ`!bHG1=$lwaxm!GOnRbR15F$nRC-M*H<*VfF|pQw(;tbSfp({>9^5 zw_M1-SJ9eGF~m(0dvp*P8uaA0Yw+EkP-SWqu zqal$hK8SmM7#Mrs0@OD+%_J%H*bMyZiWAZdsIBj#lkZ!l2c&IpLu(5^T0Ge5PHzR} zn;TXs$+IQ_&;O~u=Jz+XE0wbOy`=6>m9JVG} zJ~Kp1e5m?K3x@@>!D)piw^eMIHjD4RebtR`|IlckplP1;r21wTi8v((KqNqn%2CB< zifaQc&T}*M&0i|LW^LgdjIaX|o~I$`owHolRqeH_CFrqCUCleN130&vH}dK|^kC>) z-r2P~mApHotL4dRX$25lIcRh_*kJaxi^%ZN5-GAAMOxfB!6flLPY-p&QzL9TE%ho( zRwftE3sy5<*^)qYzKkL|rE>n@hyr;xPqncY6QJ8125!MWr`UCWuC~A#G1AqF1@V$kv>@NBvN&2ygy*{QvxolkRRb%Ui zsmKROR%{*g*WjUUod@@cS^4eF^}yQ1>;WlGwOli z+Y$(8I`0(^d|w>{eaf!_BBM;NpCoeem2>J}82*!em=}}ymoXk>QEfJ>G(3LNA2-46 z5PGvjr)Xh9>aSe>vEzM*>xp{tJyZox1ZRl}QjcvX2TEgNc^(_-hir@Es>NySoa1g^ zFow_twnHdx(j?Q_3q51t3XI7YlJ4_q&(0#)&a+RUy{IcBq?)eaWo*=H2UUVIqtp&lW9JTJiP&u zw8+4vo~_IJXZIJb_U^&=GI1nSD%e;P!c{kZALNCm5c%%oF+I3DrA63_@4)(v4(t~JiddILp7jmoy+>cD~ivwoctFfEL zP*#2Rx?_&bCpX26MBgp^4G>@h`Hxc(lnqyj!*t>9sOBcXN(hTwEDpn^X{x!!gPX?1 z*uM$}cYRwHXuf+gYTB}gDTcw{TXSOUU$S?8BeP&sc!Lc{{pEv}x#ELX>6*ipI1#>8 zKes$bHjiJ1OygZge_ak^Hz#k;=od1wZ=o71ba7oClBMq>Uk6hVq|ePPt)@FM5bW$I z;d2Or@wBjbTyZj|;+iHp%Bo!Vy(X3YM-}lasMItEV_QrP-Kk_J4C>)L&I3Xxj=E?| zsAF(IfVQ4w+dRRnJ>)}o^3_012YYgFWE)5TT=l2657*L8_u1KC>Y-R{7w^S&A^X^U}h20jpS zQsdeaA#WIE*<8KG*oXc~$izYilTc#z{5xhpXmdT-YUnGh9v4c#lrHG6X82F2-t35} zB`jo$HjKe~E*W$=g|j&P>70_cI`GnOQ;Jp*JK#CT zuEGCn{8A@bC)~0%wsEv?O^hSZF*iqjO~_h|>xv>PO+?525Nw2472(yqS>(#R)D7O( zg)Zrj9n9$}=~b00=Wjf?E418qP-@8%MQ%PBiCTX=$B)e5cHFDu$LnOeJ~NC;xmOk# z>z&TbsK>Qzk)!88lNI8fOE2$Uxso^j*1fz>6Ot49y@=po)j4hbTIcVR`ePHpuJSfp zxaD^Dn3X}Na3@<_Pc>a;-|^Pon(>|ytG_+U^8j_JxP=_d>L$Hj?|0lz>_qQ#a|$+( z(x=Lipuc8p4^}1EQhI|TubffZvB~lu$zz9ao%T?%ZLyV5S9}cLeT?c} z>yCN9<04NRi~1oR)CiBakoNhY9BPnv)kw%*iv8vdr&&VgLGIs(-FbJ?d_gfbL2={- zBk4lkdPk~7+jIxd4{M(-W1AC_WcN&Oza@jZoj zaE*9Y;g83#m(OhA!w~LNfUJNUuRz*H-=$s*z+q+;snKPRm9EptejugC-@7-a-}Tz0 z@KHra#Y@OXK+KsaSN9WiGf?&jlZ!V7L||%KHP;SLksMFfjkeIMf<1e~t?!G3{n)H8 zQAlFY#QwfKuj;l@<$YDATAk;%PtD%B(0<|8>rXU< zJ66rkAVW_~Dj!7JGdGGi4NFuE?7ZafdMxIh65Sz7yQoA7fBZCE@WwysB=+`kT^LFX zz8#FlSA5)6FG9(qL3~A24mpzL@@2D#>0J7mMS1T*9UJ zvOq!!a(%IYY69+h45CE?(&v9H4FCr>gK0>mK~F}5RdOuH2{4|}k@5XpsX7+LZo^Qa4sH5`eUj>iffoBVm+ zz4Mtf`h?NW$*q1yr|}E&eNl)J``SZvTf6Qr*&S%tVv_OBpbjnA0&Vz#(;QmGiq-k! zgS0br4I&+^2mgA15*~Cd00cXLYOLA#Ep}_)eED>m+K@JTPr_|lSN}(OzFXQSBc6fM z@f-%2;1@BzhZa*LFV z-LrLmkmB%<<&jEURBEW>soaZ*rSIJNwaV%-RSaCZi4X)qYy^PxZ=oL?6N-5OGOMD2 z;q_JK?zkwQ@b3~ln&sDtT5SpW9a0q+5Gm|fpVY2|zqlNYBR}E5+ahgdj!CvK$Tlk0 z9g$5N;aar=CqMsudQV>yb4l@hN(9Jcc=1(|OHsqH6|g=K-WBd8GxZ`AkT?OO z-z_Ued-??Z*R4~L7jwJ%-`s~FK|qNAJ;EmIVDVpk{Lr7T4l{}vL)|GuUuswe9c5F| zv*5%u01hlv08?00Vpwyk*Q&&fY8k6MjOfpZfKa@F-^6d=Zv|0@&4_544RP5(s|4VPVP-f>%u(J@23BHqo2=zJ#v9g=F!cP((h zpt0|(s++ej?|$;2PE%+kc6JMmJjDW)3BXvBK!h!E`8Y&*7hS{c_Z?4SFP&Y<3evqf z9-ke+bSj$%Pk{CJlJbWwlBg^mEC^@%Ou?o>*|O)rl&`KIbHrjcpqsc$Zqt0^^F-gU2O=BusO+(Op}!jNzLMc zT;0YT%$@ClS%V+6lMTfhuzzxomoat=1H?1$5Ei7&M|gxo`~{UiV5w64Np6xV zVK^nL$)#^tjhCpTQMspXI({TW^U5h&Wi1Jl8g?P1YCV4=%ZYyjSo#5$SX&`r&1PyC zzc;uzCd)VTIih|8eNqFNeBMe#j_FS6rq81b>5?aXg+E#&$m++Gz9<+2)h=K(xtn}F ziV{rmu+Y>A)qvF}ms}4X^Isy!M&1%$E!rTO~5(p+8{U6#hWu>(Ll1}eD64Xa>~73A*538wry?v$vW z>^O#FRdbj(k0Nr&)U`Tl(4PI*%IV~;ZcI2z&rmq=(k^}zGOYZF3b2~Klpzd2eZJl> zB=MOLwI1{$RxQ7Y4e30&yOx?BvAvDkTBvWPpl4V8B7o>4SJn*+h1Ms&fHso%XLN5j z-zEwT%dTefp~)J_C8;Q6i$t!dnlh-!%haR1X_NuYUuP-)`IGWjwzAvp!9@h`kPZhf zwLwFk{m3arCdx8rD~K2`42mIN4}m%OQ|f)4kf%pL?Af5Ul<3M2fv>;nlhEPR8b)u} zIV*2-wyyD%%) zl$G@KrC#cUwoL?YdQyf9WH)@gWB{jd5w4evI& zOFF)p_D8>;3-N1z6mES!OPe>B^<;9xsh)){Cw$Vs-ez5nXS95NOr3s$IU;>VZSzKn zBvub8_J~I%(DozZW@{)Vp37-zevxMRZ8$8iRfwHmYvyjOxIOAF2FUngKj289!(uxY zaClWm!%x&teKmr^ABrvZ(ikx{{I-lEzw5&4t3P0eX%M~>$wG0ZjA4Mb&op+0$#SO_ z--R`>X!aqFu^F|a!{Up-iF(K+alKB{MNMs>e(i@Tpy+7Z-dK%IEjQFO(G+2mOb@BO zP>WHlS#fSQm0et)bG8^ZDScGnh-qRKIFz zfUdnk=m){ej0i(VBd@RLtRq3Ep=>&2zZ2%&vvf?Iex01hx1X!8U+?>ER;yJlR-2q4 z;Y@hzhEC=d+Le%=esE>OQ!Q|E%6yG3V_2*uh&_nguPcZ{q?DNq8h_2ahaP6=pP-+x zK!(ve(yfoYC+n(_+chiJ6N(ZaN+XSZ{|H{TR1J_s8x4jpis-Z-rlRvRK#U%SMJ(`C z?T2 zF(NNfO_&W%2roEC2j#v*(nRgl1X)V-USp-H|CwFNs?n@&vpRcj@W@xCJwR6@T!jt377?XjZ06=`d*MFyTdyvW!`mQm~t3luzYzvh^F zM|V}rO>IlBjZc}9Z zd$&!tthvr>5)m;5;96LWiAV0?t)7suqdh0cZis`^Pyg@?t>Ms~7{nCU;z`Xl+raSr zXpp=W1oHB*98s!Tpw=R5C)O{{Inl>9l7M*kq%#w9a$6N~v?BY2GKOVRkXYCgg*d

<5G2M1WZP5 zzqSuO91lJod(SBDDw<*sX(+F6Uq~YAeYV#2A;XQu_p=N5X+#cmu19Qk>QAnV=k!?wbk5I;tDWgFc}0NkvC*G=V+Yh1cyeJVq~9czZiDXe+S=VfL2g`LWo8om z$Y~FQc6MFjV-t1Y`^D9XMwY*U_re2R?&(O~68T&D4S{X`6JYU-pz=}ew-)V0AOUT1 zVOkHAB-8uBcRjLvz<9HS#a@X*Kc@|W)nyiSgi|u5$Md|P()%2(?olGg@ypoJwp6>m z*dnfjjWC>?_1p;%1brqZyDRR;8EntVA92EJ3ByOxj6a+bhPl z;a?m4rQAV1@QU^#M1HX)0+}A<7TCO`ZR_RzF}X9-M>cRLyN4C+lCk2)kT^3gN^`IT zNP~fAm(wyIoR+l^lQDA(e1Yv}&$I!n?&*p6?lZcQ+vGLLd~fM)qt}wsbf3r=tmVYe zl)ntf#E!P7wlakP9MXS7m0nsAmqxZ*)#j;M&0De`oNmFgi$ov#!`6^4)iQyxg5Iuj zjLAhzQ)r`^hf7`*1`Rh`X;LVBtDSz@0T?kkT1o!ijeyTGt5vc^Cd*tmNgiNo^EaWvaC8$e+nb_{W01j3%=1Y&92YacjCi>eNbwk%-gPQ@H-+4xskQ}f_c=jg^S-# zYFBDf)2?@5cy@^@FHK5$YdAK9cI;!?Jgd}25lOW%xbCJ>By3=HiK@1EM+I46A)Lsd zeT|ZH;KlCml=@;5+hfYf>QNOr^XNH%J-lvev)$Omy8MZ`!{`j>(J5cG&ZXXgv)TaF zg;cz99i$4CX_@3MIb?GL0s*8J=3`#P(jXF(_(6DXZjc@(@h&=M&JG)9&Te1?(^XMW zjjC_70|b=9hB6pKQi`S^Ls7JyJw^@P>Ko^&q8F&?>6i;#CbxUiLz1ZH4lNyd@QACd zu>{!sqjB!2Dg}pbAXD>d!3jW}=5aN0b;rw*W>*PAxm7D)aw(c*RX2@bTGEI|RRp}vw7;NR2wa;rXN{L{Q#=Fa z$x@ms6pqb>!8AuV(prv>|aU8oWV={C&$c zMa=p=CDNOC2tISZcd8~18GN5oTbKY+Vrq;3_obJlfSKRMk;Hdp1`y`&LNSOqeauR_ z^j*Ojl3Ohzb5-a49A8s|UnM*NM8tg}BJXdci5%h&;$afbmRpN0&~9rCnBA`#lG!p zc{(9Y?A0Y9yo?wSYn>iigf~KP$0*@bGZ>*YM4&D;@{<%Gg5^uUJGRrV4 z(aZOGB&{_0f*O=Oi0k{@8vN^BU>s3jJRS&CJOl3o|BE{FAA&a#2YYiX3pZz@|Go-F z|Fly;7eX2OTs>R}<`4RwpHFs9nwh)B28*o5qK1Ge=_^w0m`uJOv!=&!tzt#Save(C zgKU=Bsgql|`ui(e1KVxR`?>Dx>(rD1$iWp&m`v)3A!j5(6vBm*z|aKm*T*)mo(W;R zNGo2`KM!^SS7+*9YxTm6YMm_oSrLceqN*nDOAtagULuZl5Q<7mOnB@Hq&P|#9y{5B z!2x+2s<%Cv2Aa0+u{bjZXS);#IFPk(Ph-K7K?3i|4ro> zRbqJoiOEYo(Im^((r}U4b8nvo_>4<`)ut`24?ILnglT;Pd&U}$lV3U$F9#PD(O=yV zgNNA=GW|(E=&m_1;uaNmipQe?pon4{T=zK!N!2_CJL0E*R^XXIKf*wi!>@l}3_P9Z zF~JyMbW!+n-+>!u=A1ESxzkJy$DRuG+$oioG7(@Et|xVbJ#BCt;J43Nvj@MKvTxzy zMmjNuc#LXBxFAwIGZJk~^!q$*`FME}yKE8d1f5Mp}KHNq(@=Z8YxV}0@;YS~|SpGg$_jG7>_8WWYcVx#4SxpzlV9N4aO>K{c z$P?a_fyDzGX$Of3@ykvedGd<@-R;M^Shlj*SswJLD+j@hi_&_>6WZ}#AYLR0iWMK|A zH_NBeu(tMyG=6VO-=Pb>-Q#$F*or}KmEGg*-n?vWQREURdB#+6AvOj*I%!R-4E_2$ zU5n9m>RWs|Wr;h2DaO&mFBdDb-Z{APGQx$(L`if?C|njd*fC=rTS%{o69U|meRvu?N;Z|Y zbT|ojL>j;q*?xXmnHH#3R4O-59NV1j=uapkK7}6@Wo*^Nd#(;$iuGsb;H315xh3pl zHaJ>h-_$hdNl{+|Zb%DZH%ES;*P*v0#}g|vrKm9;j-9e1M4qX@zkl&5OiwnCz=tb6 zz<6HXD+rGIVpGtkb{Q^LIgExOm zz?I|oO9)!BOLW#krLmWvX5(k!h{i>ots*EhpvAE;06K|u_c~y{#b|UxQ*O@Ks=bca z^_F0a@61j3I(Ziv{xLb8AXQj3;R{f_l6a#H5ukg5rxwF9A$?Qp-Mo54`N-SKc}fWp z0T)-L@V$$&my;l#Ha{O@!fK4-FSA)L&3<${Hcwa7ue`=f&YsXY(NgeDU#sRlT3+9J z6;(^(sjSK@3?oMo$%L-nqy*E;3pb0nZLx6 z;h5)T$y8GXK1DS-F@bGun8|J(v-9o=42&nLJy#}M5D0T^5VWBNn$RpC zZzG6Bt66VY4_?W=PX$DMpKAI!d`INr) zkMB{XPQ<52rvWVQqgI0OL_NWxoe`xxw&X8yVftdODPj5|t}S6*VMqN$-h9)1MBe0N zYq?g0+e8fJCoAksr0af1)FYtz?Me!Cxn`gUx&|T;)695GG6HF7!Kg1zzRf_{VWv^bo81v4$?F6u2g|wxHc6eJQAg&V z#%0DnWm2Rmu71rPJ8#xFUNFC*V{+N_qqFH@gYRLZ6C?GAcVRi>^n3zQxORPG)$-B~ z%_oB?-%Zf7d*Fe;cf%tQwcGv2S?rD$Z&>QC2X^vwYjnr5pa5u#38cHCt4G3|efuci z@3z=#A13`+ztmp;%zjXwPY_aq-;isu*hecWWX_=Z8paSqq7;XYnUjK*T>c4~PR4W7 z#C*%_H&tfGx`Y$w7`dXvVhmovDnT>btmy~SLf>>~84jkoQ%cv=MMb+a{JV&t0+1`I z32g_Y@yDhKe|K^PevP~MiiVl{Ou7^Mt9{lOnXEQ`xY^6L8D$705GON{!1?1&YJEl#fTf5Z)da=yiEQ zGgtC-soFGOEBEB~ZF_{7b(76En>d}mI~XIwNw{e>=Fv)sgcw@qOsykWr?+qAOZSVrQfg}TNI ztKNG)1SRrAt6#Q?(me%)>&A_^DM`pL>J{2xu>xa$3d@90xR61TQDl@fu%_85DuUUA za9tn64?At;{`BAW6oykwntxHeDpXsV#{tmt5RqdN7LtcF4vR~_kZNT|wqyR#z^Xcd zFdymVRZvyLfTpBT>w9<)Ozv@;Yk@dOSVWbbtm^y@@C>?flP^EgQPAwsy75bveo=}T zFxl(f)s)j(0#N_>Or(xEuV(n$M+`#;Pc$1@OjXEJZumkaekVqgP_i}p`oTx;terTx zZpT+0dpUya2hqlf`SpXN{}>PfhajNk_J0`H|2<5E;U5Vh4F8er z;RxLSFgpGhkU>W?IwdW~NZTyOBrQ84H7_?gviIf71l`EETodG9a1!8e{jW?DpwjL? zGEM&eCzwoZt^P*8KHZ$B<%{I}>46IT%jJ3AnnB5P%D2E2Z_ z1M!vr#8r}1|KTqWA4%67ZdbMW2YJ81b(KF&SQ2L1Qn(y-=J${p?xLMx3W7*MK;LFQ z6Z`aU;;mTL4XrrE;HY*Rkh6N%?qviUGNAKiCB~!P}Z->IpO6E(gGd7I#eDuT7j|?nZ zK}I(EJ>$Kb&@338M~O+em9(L!+=0zBR;JAQesx|3?Ok90)D1aS9P?yTh6Poh8Cr4X zk3zc=f2rE7jj+aP7nUsr@~?^EGP>Q>h#NHS?F{Cn`g-gD<8F&dqOh-0sa%pfL`b+1 zUsF*4a~)KGb4te&K0}bE>z3yb8% zibb5Q%Sfiv7feb1r0tfmiMv z@^4XYwg@KZI=;`wC)`1jUA9Kv{HKe2t$WmRcR4y8)VAFjRi zaz&O7Y2tDmc5+SX(bj6yGHYk$dBkWc96u3u&F)2yEE~*i0F%t9Kg^L6MJSb&?wrXi zGSc;_rln$!^ybwYBeacEFRsVGq-&4uC{F)*Y;<0y7~USXswMo>j4?~5%Zm!m@i@-> zXzi82sa-vpU{6MFRktJy+E0j#w`f`>Lbog{zP|9~hg(r{RCa!uGe>Yl536cn$;ouH za#@8XMvS-kddc1`!1LVq;h57~zV`7IYR}pp3u!JtE6Q67 zq3H9ZUcWPm2V4IukS}MCHSdF0qg2@~ufNx9+VMjQP&exiG_u9TZAeAEj*jw($G)zL zq9%#v{wVyOAC4A~AF=dPX|M}MZV)s(qI9@aIK?Pe+~ch|>QYb+78lDF*Nxz2-vpRbtQ*F4$0fDbvNM#CCatgQ@z1+EZWrt z2dZfywXkiW=no5jus-92>gXn5rFQ-COvKyegmL=4+NPzw6o@a?wGE-1Bt;pCHe;34K%Z z-FnOb%!nH;)gX+!a3nCk?5(f1HaWZBMmmC@lc({dUah+E;NOros{?ui1zPC-Q0);w zEbJmdE$oU$AVGQPdm{?xxI_0CKNG$LbY*i?YRQ$(&;NiA#h@DCxC(U@AJ$Yt}}^xt-EC_ z4!;QlLkjvSOhdx!bR~W|Ezmuf6A#@T`2tsjkr>TvW*lFCMY>Na_v8+{Y|=MCu1P8y z89vPiH5+CKcG-5lzk0oY>~aJC_0+4rS@c@ZVKLAp`G-sJB$$)^4*A!B zmcf}lIw|VxV9NSoJ8Ag3CwN&d7`|@>&B|l9G8tXT^BDHOUPrtC70NgwN4${$k~d_4 zJ@eo6%YQnOgq$th?0{h`KnqYa$Nz@vlHw<%!C5du6<*j1nwquk=uY}B8r7f|lY+v7 zm|JU$US08ugor8E$h3wH$c&i~;guC|3-tqJy#T;v(g( zBZtPMSyv%jzf->435yM(-UfyHq_D=6;ouL4!ZoD+xI5uCM5ay2m)RPmm$I}h>()hS zO!0gzMxc`BPkUZ)WXaXam%1;)gedA7SM8~8yIy@6TPg!hR0=T>4$Zxd)j&P-pXeSF z9W`lg6@~YDhd19B9ETv(%er^Xp8Yj@AuFVR_8t*KS;6VHkEDKI#!@l!l3v6`W1`1~ zP{C@keuV4Q`Rjc08lx?zmT$e$!3esc9&$XZf4nRL(Z*@keUbk!GZi(2Bmyq*saOD? z3Q$V<*P-X1p2}aQmuMw9nSMbOzuASsxten7DKd6A@ftZ=NhJ(0IM|Jr<91uAul4JR zADqY^AOVT3a(NIxg|U;fyc#ZnSzw2cr}#a5lZ38>nP{05D)7~ad7JPhw!LqOwATXtRhK!w0X4HgS1i<%AxbFmGJx9?sEURV+S{k~g zGYF$IWSlQonq6}e;B(X(sIH|;52+(LYW}v_gBcp|x%rEAVB`5LXg_d5{Q5tMDu0_2 z|LOm$@K2?lrLNF=mr%YP|U-t)~9bqd+wHb4KuPmNK<}PK6e@aosGZK57=Zt+kcszVOSbe;`E^dN! ze7`ha3WUUU7(nS0{?@!}{0+-VO4A{7+nL~UOPW9_P(6^GL0h${SLtqG!} zKl~Ng5#@Sy?65wk9z*3SA`Dpd4b4T^@C8Fhd8O)k_4%0RZL5?#b~jmgU+0|DB%0Z) zql-cPC>A9HPjdOTpPC` zQwvF}uB5kG$Xr4XnaH#ruSjM*xG?_hT7y3G+8Ox`flzU^QIgb_>2&-f+XB6MDr-na zSi#S+c!ToK84<&m6sCiGTd^8pNdXo+$3^l3FL_E`0 z>8it5YIDxtTp2Tm(?}FX^w{fbfgh7>^8mtvN>9fWgFN_*a1P`Gz*dyOZF{OV7BC#j zQV=FQM5m>47xXgapI$WbPM5V`V<7J9tD)oz@d~MDoM`R^Y6-Na(lO~uvZlpu?;zw6 zVO1faor3dg#JEb5Q*gz4<W8tgC3nE2BG2jeIQs1)<{In&7hJ39x=;ih;CJDy)>0S1at*7n?Wr0ahYCpFjZ|@u91Zl7( zv;CSBRC65-6f+*JPf4p1UZ)k=XivKTX6_bWT~7V#rq0Xjas6hMO!HJN8GdpBKg_$B zwDHJF6;z?h<;GXFZan8W{XFNPpOj!(&I1`&kWO86p?Xz`a$`7qV7Xqev|7nn_lQuX ziGpU1MMYt&5dE2A62iX3;*0WzNB9*nSTzI%62A+N?f?;S>N@8M=|ef3gtQTIA*=yq zQAAjOqa!CkHOQo4?TsqrrsJLclXcP?dlAVv?v`}YUjo1Htt;6djP@NPFH+&p1I+f_ z)Y279{7OWomY8baT(4TAOlz1OyD{4P?(DGv3XyJTA2IXe=kqD)^h(@*E3{I~w;ws8 z)ZWv7E)pbEM zd3MOXRH3mQhks9 zv6{s;k0y5vrcjXaVfw8^>YyPo=oIqd5IGI{)+TZq5Z5O&hXAw%ZlL}^6FugH;-%vP zAaKFtt3i^ag226=f0YjzdPn6|4(C2sC5wHFX{7QF!tG1E-JFA`>eZ`}$ymcRJK?0c zN363o{&ir)QySOFY0vcu6)kX#;l??|7o{HBDVJN+17rt|w3;(C_1b>d;g9Gp=8YVl zYTtA52@!7AUEkTm@P&h#eg+F*lR zQ7iotZTcMR1frJ0*V@Hw__~CL>_~2H2cCtuzYIUD24=Cv!1j6s{QS!v=PzwQ(a0HS zBKx04KA}-Ue+%9d`?PG*hIij@54RDSQpA7|>qYVIrK_G6%6;#ZkR}NjUgmGju)2F`>|WJoljo)DJgZr4eo1k1i1+o z1D{>^RlpIY8OUaOEf5EBu%a&~c5aWnqM zxBpJq98f=%M^{4mm~5`CWl%)nFR64U{(chmST&2jp+-r z3675V<;Qi-kJud%oWnCLdaU-)xTnMM%rx%Jw6v@=J|Ir=4n-1Z23r-EVf91CGMGNz zb~wyv4V{H-hkr3j3WbGnComiqmS0vn?n?5v2`Vi>{Ip3OZUEPN7N8XeUtF)Ry6>y> zvn0BTLCiqGroFu|m2zG-;Xb6;W`UyLw)@v}H&(M}XCEVXZQoWF=Ykr5lX3XWwyNyF z#jHv)A*L~2BZ4lX?AlN3X#axMwOC)PoVy^6lCGse9bkGjb=qz%kDa6}MOmSwK`cVO zt(e*MW-x}XtU?GY5}9{MKhRhYOlLhJE5=ca+-RmO04^ z66z{40J=s=ey9OCdc(RCzy zd7Zr1%!y3}MG(D=wM_ebhXnJ@MLi7cImDkhm0y{d-Vm81j`0mbi4lF=eirlr)oW~a zCd?26&j^m4AeXEsIUXiTal)+SPM4)HX%%YWF1?(FV47BaA`h9m67S9x>hWMVHx~Hg z1meUYoLL(p@b3?x|9DgWeI|AJ`Ia84*P{Mb%H$ZRROouR4wZhOPX15=KiBMHl!^JnCt$Az`KiH^_d>cev&f zaG2>cWf$=A@&GP~DubsgYb|L~o)cn5h%2`i^!2)bzOTw2UR!>q5^r&2Vy}JaWFUQE04v>2;Z@ZPwXr?y&G(B^@&y zsd6kC=hHdKV>!NDLIj+3rgZJ|dF`%N$DNd;B)9BbiT9Ju^Wt%%u}SvfM^=|q-nxDG zuWCQG9e#~Q5cyf8@y76#kkR^}{c<_KnZ0QsZcAT|YLRo~&tU|N@BjxOuy`#>`X~Q< z?R?-Gsk$$!oo(BveQLlUrcL#eirhgBLh`qHEMg`+sR1`A=1QX7)ZLMRT+GBy?&mM8 zQG^z-!Oa&J-k7I(3_2#Q6Bg=NX<|@X&+YMIOzfEO2$6Mnh}YV!m!e^__{W@-CTprr zbdh3f=BeCD$gHwCrmwgM3LAv3!Mh$wM)~KWzp^w)Cu6roO7uUG5z*}i0_0j47}pK; ztN530`ScGatLOL06~zO)Qmuv`h!gq5l#wx(EliKe&rz-5qH(hb1*fB#B+q`9=jLp@ zOa2)>JTl7ovxMbrif`Xe9;+fqB1K#l=Dv!iT;xF zdkCvS>C5q|O;}ns3AgoE({Ua-zNT-9_5|P0iANmC6O76Sq_(AN?UeEQJ>#b54fi3k zFmh+P%b1x3^)0M;QxXLP!BZ^h|AhOde*{9A=f3|Xq*JAs^Y{eViF|=EBfS6L%k4ip zk+7M$gEKI3?bQg?H3zaE@;cyv9kv;cqK$VxQbFEsy^iM{XXW0@2|DOu$!-k zSFl}Y=jt-VaT>Cx*KQnHTyXt}f9XswFB9ibYh+k2J!ofO+nD?1iw@mwtrqI4_i?nE zhLkPp41ED62me}J<`3RN80#vjW;wt`pP?%oQ!oqy7`miL>d-35a=qotK$p{IzeSk# ze_$CFYp_zIkrPFVaW^s#U4xT1lI^A0IBe~Y<4uS%zSV=wcuLr%gQT=&5$&K*bwqx| zWzCMiz>7t^Et@9CRUm9E+@hy~sBpm9fri$sE1zgLU((1?Yg{N1Sars=DiW&~Zw=3I zi7y)&oTC?UWD2w97xQ&5vx zRXEBGeJ(I?Y}eR0_O{$~)bMJRTsNUPIfR!xU9PE7A>AMNr_wbrFK>&vVw=Y;RH zO$mlpmMsQ}-FQ2cSj7s7GpC+~^Q~dC?y>M}%!-3kq(F3hGWo9B-Gn02AwUgJ>Z-pKOaj zysJBQx{1>Va=*e@sLb2z&RmQ7ira;aBijM-xQ&cpR>X3wP^foXM~u1>sv9xOjzZpX z0K;EGouSYD~oQ&lAafj3~EaXfFShC+>VsRlEMa9cg9i zFxhCKO}K0ax6g4@DEA?dg{mo>s+~RPI^ybb^u--^nTF>**0l5R9pocwB?_K)BG_)S zyLb&k%XZhBVr7U$wlhMqwL)_r&&n%*N$}~qijbkfM|dIWP{MyLx}X&}ES?}7i;9bW zmTVK@zR)7kE2+L42Q`n4m0VVg5l5(W`SC9HsfrLZ=v%lpef=Gj)W59VTLe+Z$8T8i z4V%5+T0t8LnM&H>Rsm5C%qpWBFqgTwL{=_4mE{S3EnBXknM&u8n}A^IIM4$s3m(Rd z>zq=CP-!9p9es2C*)_hoL@tDYABn+o#*l;6@7;knWIyDrt5EuakO99S$}n((Fj4y} zD!VvuRzghcE{!s;jC*<_H$y6!6QpePo2A3ZbX*ZzRnQq*b%KK^NF^z96CHaWmzU@f z#j;y?X=UP&+YS3kZx7;{ zDA{9(wfz7GF`1A6iB6fnXu0?&d|^p|6)%3$aG0Uor~8o? z*e}u#qz7Ri?8Uxp4m_u{a@%bztvz-BzewR6bh*1Xp+G=tQGpcy|4V_&*aOqu|32CM zz3r*E8o8SNea2hYJpLQ-_}R&M9^%@AMx&`1H8aDx4j%-gE+baf2+9zI*+Pmt+v{39 zDZ3Ix_vPYSc;Y;yn68kW4CG>PE5RoaV0n@#eVmk?p$u&Fy&KDTy!f^Hy6&^-H*)#u zdrSCTJPJw?(hLf56%2;_3n|ujUSJOU8VPOTlDULwt0jS@j^t1WS z!n7dZIoT+|O9hFUUMbID4Ec$!cc($DuQWkocVRcYSikFeM&RZ=?BW)mG4?fh#)KVG zcJ!<=-8{&MdE)+}?C8s{k@l49I|Zwswy^ZN3;E!FKyglY~Aq?4m74P-0)sMTGXqd5(S<-(DjjM z&7dL-Mr8jhUCAG$5^mI<|%`;JI5FVUnNj!VO2?Jiqa|c2;4^n!R z`5KK0hyB*F4w%cJ@Un6GC{mY&r%g`OX|1w2$B7wxu97%<@~9>NlXYd9RMF2UM>(z0 zouu4*+u+1*k;+nFPk%ly!nuMBgH4sL5Z`@Rok&?Ef=JrTmvBAS1h?C0)ty5+yEFRz zY$G=coQtNmT@1O5uk#_MQM1&bPPnspy5#>=_7%WcEL*n$;sSAZcXxMpcXxLe;_mLA z5F_paad+bGZV*oh@8h0(|D2P!q# zTHjmiphJ=AazSeKQPkGOR-D8``LjzToyx{lfK-1CDD6M7?pMZOdLKFtjZaZMPk4}k zW)97Fh(Z+_Fqv(Q_CMH-YYi?fR5fBnz7KOt0*t^cxmDoIokc=+`o# zrud|^h_?KW=Gv%byo~(Ln@({?3gnd?DUf-j2J}|$Mk>mOB+1{ZQ8HgY#SA8END(Zw z3T+W)a&;OO54~m}ffemh^oZ!Vv;!O&yhL0~hs(p^(Yv=(3c+PzPXlS5W79Er8B1o* z`c`NyS{Zj_mKChj+q=w)B}K za*zzPhs?c^`EQ;keH{-OXdXJet1EsQ)7;{3eF!-t^4_Srg4(Ot7M*E~91gwnfhqaM zNR7dFaWm7MlDYWS*m}CH${o?+YgHiPC|4?X?`vV+ws&Hf1ZO-w@OGG^o4|`b{bLZj z&9l=aA-Y(L11!EvRjc3Zpxk7lc@yH1e$a}8$_-r$)5++`_eUr1+dTb@ zU~2P1HM#W8qiNN3b*=f+FfG1!rFxnNlGx{15}BTIHgxO>Cq4 z;#9H9YjH%>Z2frJDJ8=xq>Z@H%GxXosS@Z>cY9ppF+)e~t_hWXYlrO6)0p7NBMa`+ z^L>-#GTh;k_XnE)Cgy|0Dw;(c0* zSzW14ZXozu)|I@5mRFF1eO%JM=f~R1dkNpZM+Jh(?&Zje3NgM{2ezg1N`AQg5%+3Y z64PZ0rPq6;_)Pj-hyIOgH_Gh`1$j1!jhml7ksHA1`CH3FDKiHLz+~=^u@kUM{ilI5 z^FPiJ7mSrzBs9{HXi2{sFhl5AyqwUnU{sPcUD{3+l-ZHAQ)C;c$=g1bdoxeG(5N01 zZy=t8i{*w9m?Y>V;uE&Uy~iY{pY4AV3_N;RL_jT_QtLFx^KjcUy~q9KcLE3$QJ{!)@$@En{UGG7&}lc*5Kuc^780;7Bj;)X?1CSy*^^ zPP^M)Pr5R>mvp3_hmCtS?5;W^e@5BjE>Cs<`lHDxj<|gtOK4De?Sf0YuK5GX9G93i zMYB{8X|hw|T6HqCf7Cv&r8A$S@AcgG1cF&iJ5=%+x;3yB`!lQ}2Hr(DE8=LuNb~Vs z=FO&2pdc16nD$1QL7j+!U^XWTI?2qQKt3H8=beVTdHHa9=MiJ&tM1RRQ-=+vy!~iz zj3O{pyRhCQ+b(>jC*H)J)%Wq}p>;?@W*Eut@P&?VU+Sdw^4kE8lvX|6czf{l*~L;J zFm*V~UC;3oQY(ytD|D*%*uVrBB}BbAfjK&%S;z;7$w68(8PV_whC~yvkZmX)xD^s6 z{$1Q}q;99W?*YkD2*;)tRCS{q2s@JzlO~<8x9}X<0?hCD5vpydvOw#Z$2;$@cZkYrp83J0PsS~!CFtY%BP=yxG?<@#{7%2sy zOc&^FJxsUYN36kSY)d7W=*1-{7ghPAQAXwT7z+NlESlkUH&8ODlpc8iC*iQ^MAe(B z?*xO4i{zFz^G=^G#9MsLKIN64rRJykiuIVX5~0#vAyDWc9-=6BDNT_aggS2G{B>dD ze-B%d3b6iCfc5{@yz$>=@1kdK^tX9qh0=ocv@9$ai``a_ofxT=>X7_Y0`X}a^M?d# z%EG)4@`^Ej_=%0_J-{ga!gFtji_byY&Vk@T1c|ucNAr(JNr@)nCWj?QnCyvXg&?FW;S-VOmNL6^km_dqiVjJuIASVGSFEos@EVF7St$WE&Z%)`Q##+0 zjaZ=JI1G@0!?l|^+-ZrNd$WrHBi)DA0-Eke>dp=_XpV<%CO_Wf5kQx}5e<90dt>8k zAi00d0rQ821nA>B4JHN7U8Zz=0;9&U6LOTKOaC1FC8GgO&kc=_wHIOGycL@c*$`ce703t%>S}mvxEnD-V!;6c`2(p74V7D0No1Xxt`urE66$0(ThaAZ1YVG#QP$ zy~NN%kB*zhZ2Y!kjn826pw4bh)75*e!dse+2Db(;bN34Uq7bLpr47XTX{8UEeC?2i z*{$`3dP}32${8pF$!$2Vq^gY|#w+VA_|o(oWmQX8^iw#n_crb(K3{69*iU?<%C-%H zuKi)3M1BhJ@3VW>JA`M>L~5*_bxH@Euy@niFrI$82C1}fwR$p2E&ZYnu?jlS}u7W9AyfdXh2pM>78bIt3 z)JBh&XE@zA!kyCDfvZ1qN^np20c1u#%P6;6tU&dx0phT1l=(mw7`u!-0e=PxEjDds z9E}{E!7f9>jaCQhw)&2TtG-qiD)lD(4jQ!q{`x|8l&nmtHkdul# zy+CIF8lKbp9_w{;oR+jSLtTfE+B@tOd6h=QePP>rh4@~!8c;Hlg9m%%&?e`*Z?qz5-zLEWfi>`ord5uHF-s{^bexKAoMEV@9nU z^5nA{f{dW&g$)BAGfkq@r5D)jr%!Ven~Q58c!Kr;*Li#`4Bu_?BU0`Y`nVQGhNZk@ z!>Yr$+nB=`z#o2nR0)V3M7-eVLuY`z@6CT#OTUXKnxZn$fNLPv7w1y7eGE=Qv@Hey`n;`U=xEl|q@CCV^#l)s0ZfT+mUf z^(j5r4)L5i2jnHW4+!6Si3q_LdOLQi<^fu?6WdohIkn79=jf%Fs3JkeXwF(?_tcF? z?z#j6iXEd(wJy4|p6v?xNk-)iIf2oX5^^Y3q3ziw16p9C6B;{COXul%)`>nuUoM*q zzmr|NJ5n)+sF$!yH5zwp=iM1#ZR`O%L83tyog-qh1I z0%dcj{NUs?{myT~33H^(%0QOM>-$hGFeP;U$puxoJ>>o-%Lk*8X^rx1>j|LtH$*)>1C!Pv&gd16%`qw5LdOIUbkNhaBBTo}5iuE%K&ZV^ zAr_)kkeNKNYJRgjsR%vexa~&8qMrQYY}+RbZ)egRg9_$vkoyV|Nc&MH@8L)`&rpqd zXnVaI@~A;Z^c3+{x=xgdhnocA&OP6^rr@rTvCnhG6^tMox$ulw2U7NgUtW%|-5VeH z_qyd47}1?IbuKtqNbNx$HR`*+9o=8`%vM8&SIKbkX9&%TS++x z5|&6P<%=F$C?owUI`%uvUq^yW0>`>yz!|WjzsoB9dT;2Dx8iSuK%%_XPgy0dTD4kd zDXF@&O_vBVVKQq(9YTClUPM30Sk7B!v7nOyV`XC!BA;BIVwphh+c)?5VJ^(C;GoQ$ zvBxr7_p*k$T%I1ke}`U&)$uf}I_T~#3XTi53OX)PoXVgxEcLJgZG^i47U&>LY(l%_ z;9vVDEtuMCyu2fqZeez|RbbIE7@)UtJvgAcVwVZNLccswxm+*L&w`&t=ttT=sv6Aq z!HouSc-24Y9;0q$>jX<1DnnGmAsP))- z^F~o99gHZw`S&Aw7e4id6Lg7kMk-e)B~=tZ!kE7sGTOJ)8@q}np@j7&7Sy{2`D^FH zI7aX%06vKsfJ168QnCM2=l|i>{I{%@gcr>ExM0Dw{PX6ozEuqFYEt z087%MKC;wVsMV}kIiuu9Zz9~H!21d!;Cu#b;hMDIP7nw3xSX~#?5#SSjyyg+Y@xh| z%(~fv3`0j#5CA2D8!M2TrG=8{%>YFr(j)I0DYlcz(2~92?G*?DeuoadkcjmZszH5& zKI@Lis%;RPJ8mNsbrxH@?J8Y2LaVjUIhRUiO-oqjy<&{2X~*f|)YxnUc6OU&5iac= z*^0qwD~L%FKiPmlzi&~a*9sk2$u<7Al=_`Ox^o2*kEv?p`#G(p(&i|ot8}T;8KLk- zPVf_4A9R`5^e`Om2LV*cK59EshYXse&IoByj}4WZaBomoHAPKqxRKbPcD`lMBI)g- zeMRY{gFaUuecSD6q!+b5(?vAnf>c`Z(8@RJy%Ulf?W~xB1dFAjw?CjSn$ph>st5bc zUac1aD_m6{l|$#g_v6;=32(mwpveQDWhmjR7{|B=$oBhz`7_g7qNp)n20|^^op3 zSfTdWV#Q>cb{CMKlWk91^;mHap{mk)o?udk$^Q^^u@&jd zfZ;)saW6{e*yoL6#0}oVPb2!}r{pAUYtn4{P~ES9tTfC5hXZnM{HrC8^=Pof{G4%Bh#8 ze~?C9m*|fd8MK;{L^!+wMy>=f^8b&y?yr6KnTq28$pFMBW9Oy7!oV5z|VM$s-cZ{I|Xf@}-)1=$V&x7e;9v81eiTi4O5-vs?^5pCKy2l>q);!MA zS!}M48l$scB~+Umz}7NbwyTn=rqt@`YtuwiQSMvCMFk2$83k50Q>OK5&fe*xCddIm)3D0I6vBU<+!3=6?(OhkO|b4fE_-j zimOzyfBB_*7*p8AmZi~X2bgVhyPy>KyGLAnOpou~sx9)S9%r)5dE%ADs4v%fFybDa_w*0?+>PsEHTbhKK^G=pFz z@IxLTCROWiKy*)cV3y%0FwrDvf53Ob_XuA1#tHbyn%Ko!1D#sdhBo`;VC*e1YlhrC z?*y3rp86m#qI|qeo8)_xH*G4q@70aXN|SP+6MQ!fJQqo1kwO_v7zqvUfU=Gwx`CR@ zRFb*O8+54%_8tS(ADh}-hUJzE`s*8wLI>1c4b@$al)l}^%GuIXjzBK!EWFO8W`>F^ ze7y#qPS0NI7*aU)g$_ziF(1ft;2<}6Hfz10cR8P}67FD=+}MfhrpOkF3hFhQu;Q1y zu%=jJHTr;0;oC94Hi@LAF5quAQ(rJG(uo%BiRQ@8U;nhX)j0i?0SL2g-A*YeAqF>RVCBOTrn{0R27vu}_S zS>tX4!#&U4W;ikTE!eFH+PKw%p+B(MR2I%n#+m0{#?qRP_tR@zpgCb=4rcrL!F=;A zh%EIF8m6%JG+qb&mEfuFTLHSxUAZEvC-+kvZKyX~SA3Umt`k}}c!5dy?-sLIM{h@> z!2=C)@nx>`;c9DdwZ&zeUc(7t<21D7qBj!|1^Mp1eZ6)PuvHx+poKSDCSBMFF{bKy z;9*&EyKitD99N}%mK8431rvbT+^%|O|HV23{;RhmS{$5tf!bIPoH9RKps`-EtoW5h zo6H_!s)Dl}2gCeGF6>aZtah9iLuGd19^z0*OryPNt{70RvJSM<#Ox9?HxGg04}b^f zrVEPceD%)#0)v5$YDE?f`73bQ6TA6wV;b^x*u2Ofe|S}+q{s5gr&m~4qGd!wOu|cZ||#h_u=k*fB;R6&k?FoM+c&J;ISg70h!J7*xGus)ta4veTdW)S^@sU@ z4$OBS=a~@F*V0ECic;ht4@?Jw<9kpjBgHfr2FDPykCCz|v2)`JxTH55?b3IM={@DU z!^|9nVO-R#s{`VHypWyH0%cs;0GO3E;It6W@0gX6wZ%W|Dzz&O%m17pa19db(er}C zUId1a4#I+Ou8E1MU$g=zo%g7K(=0Pn$)Rk z<4T2u<0rD)*j+tcy2XvY+0 z0d2pqm4)4lDewsAGThQi{2Kc3&C=|OQF!vOd#WB_`4gG3@inh-4>BoL!&#ij8bw7? zqjFRDaQz!J-YGitV4}$*$hg`vv%N)@#UdzHFI2E<&_@0Uw@h_ZHf}7)G;_NUD3@18 zH5;EtugNT0*RXVK*by>WS>jaDDfe!A61Da=VpIK?mcp^W?!1S2oah^wowRnrYjl~`lgP-mv$?yb6{{S55CCu{R z$9;`dyf0Y>uM1=XSl_$01Lc1Iy68IosWN8Q9Op=~I(F<0+_kKfgC*JggjxNgK6 z-3gQm6;sm?J&;bYe&(dx4BEjvq}b`OT^RqF$J4enP1YkeBK#>l1@-K`ajbn05`0J?0daOtnzh@l3^=BkedW1EahZlRp;`j*CaT;-21&f2wU z+Nh-gc4I36Cw+;3UAc<%ySb`#+c@5y ze~en&bYV|kn?Cn|@fqmGxgfz}U!98$=drjAkMi`43I4R%&H0GKEgx-=7PF}y`+j>r zg&JF`jomnu2G{%QV~Gf_-1gx<3Ky=Md9Q3VnK=;;u0lyTBCuf^aUi?+1+`4lLE6ZK zT#(Bf`5rmr(tgTbIt?yA@y`(Ar=f>-aZ}T~>G32EM%XyFvhn&@PWCm#-<&ApLDCXT zD#(9m|V(OOo7PmE@`vD4$S5;+9IQm19dd zvMEU`)E1_F+0o0-z>YCWqg0u8ciIknU#{q02{~YX)gc_u;8;i233D66pf(IkTDxeN zL=4z2)?S$TV9=ORVr&AkZMl<4tTh(v;Ix1{`pPVqI3n2ci&4Dg+W|N8TBUfZ*WeLF zqCH_1Q0W&f9T$lx3CFJ$o@Lz$99 zW!G&@zFHxTaP!o#z^~xgF|(vrHz8R_r9eo;TX9}2ZyjslrtH=%6O)?1?cL&BT(Amp zTGFU1%%#xl&6sH-UIJk_PGk_McFn7=%yd6tAjm|lnmr8bE2le3I~L{0(ffo}TQjyo zHZZI{-}{E4ohYTlZaS$blB!h$Jq^Rf#(ch}@S+Ww&$b);8+>g84IJcLU%B-W?+IY& zslcZIR>+U4v3O9RFEW;8NpCM0w1ROG84=WpKxQ^R`{=0MZCubg3st z48AyJNEvyxn-jCPTlTwp4EKvyEwD3e%kpdY?^BH0!3n6Eb57_L%J1=a*3>|k68A}v zaW`*4YitylfD}ua8V)vb79)N_Ixw_mpp}yJGbNu+5YYOP9K-7nf*jA1#<^rb4#AcS zKg%zCI)7cotx}L&J8Bqo8O1b0q;B1J#B5N5Z$Zq=wX~nQFgUfAE{@u0+EnmK{1hg> zC{vMfFLD;L8b4L+B51&LCm|scVLPe6h02rws@kGv@R+#IqE8>Xn8i|vRq_Z`V;x6F zNeot$1Zsu`lLS92QlLWF54za6vOEKGYQMdX($0JN*cjG7HP&qZ#3+bEN$8O_PfeAb z0R5;=zXac2IZ?fxu59?Nka;1lKm|;0)6|#RxkD05P5qz;*AL@ig!+f=lW5^Jbag%2 z%9@iM0ph$WFlxS!`p31t92z~TB}P-*CS+1Oo_g;7`6k(Jyj8m8U|Q3Sh7o-Icp4kV zK}%qri5>?%IPfamXIZ8pXbm-#{ytiam<{a5A+3dVP^xz!Pvirsq7Btv?*d7eYgx7q zWFxrzb3-%^lDgMc=Vl7^={=VDEKabTG?VWqOngE`Kt7hs236QKidsoeeUQ_^FzsXjprCDd@pW25rNx#6x&L6ZEpoX9Ffzv@olnH3rGOSW( zG-D|cV0Q~qJ>-L}NIyT?T-+x+wU%;+_GY{>t(l9dI%Ximm+Kmwhee;FK$%{dnF;C% zFjM2&$W68Sz#d*wtfX?*WIOXwT;P6NUw}IHdk|)fw*YnGa0rHx#paG!m=Y6GkS4VX zX`T$4eW9k1W!=q8!(#8A9h67fw))k_G)Q9~Q1e3f`aV@kbcSv7!priDUN}gX(iXTy zr$|kU0Vn%*ylmyDCO&G0Z3g>%JeEPFAW!5*H2Ydl>39w3W+gEUjL&vrRs(xGP{(ze zy7EMWF14@Qh>X>st8_029||TP0>7SG9on_xxeR2Iam3G~Em$}aGsNt$iES9zFa<3W zxtOF*!G@=PhfHO!=9pVPXMUVi30WmkPoy$02w}&6A7mF)G6-`~EVq5CwD2`9Zu`kd)52``#V zNSb`9dG~8(dooi1*-aSMf!fun7Sc`-C$-E(3BoSC$2kKrVcI!&yC*+ff2+C-@!AT_ zsvlAIV+%bRDfd{R*TMF><1&_a%@yZ0G0lg2K;F>7b+7A6pv3-S7qWIgx+Z?dt8}|S z>Qbb6x(+^aoV7FQ!Ph8|RUA6vXWQH*1$GJC+wXLXizNIc9p2yLzw9 z0=MdQ!{NnOwIICJc8!+Jp!zG}**r#E!<}&Te&}|B4q;U57$+pQI^}{qj669zMMe_I z&z0uUCqG%YwtUc8HVN7?0GHpu=bL7&{C>hcd5d(iFV{I5c~jpX&!(a{yS*4MEoYXh z*X4|Y@RVfn;piRm-C%b@{0R;aXrjBtvx^HO;6(>i*RnoG0Rtcd25BT6edxTNOgUAOjn zJ2)l{ipj8IP$KID2}*#F=M%^n&=bA0tY98@+2I+7~A&T-tw%W#3GV>GTmkHaqftl)#+E zMU*P(Rjo>8%P@_@#UNq(_L{}j(&-@1iY0TRizhiATJrnvwSH0v>lYfCI2ex^><3$q znzZgpW0JlQx?JB#0^^s-Js1}}wKh6f>(e%NrMwS`Q(FhazkZb|uyB@d%_9)_xb$6T zS*#-Bn)9gmobhAtvBmL+9H-+0_0US?g6^TOvE8f3v=z3o%NcPjOaf{5EMRnn(_z8- z$|m0D$FTU zDy;21v-#0i)9%_bZ7eo6B9@Q@&XprR&oKl4m>zIj-fiRy4Dqy@VVVs?rscG| zmzaDQ%>AQTi<^vYCmv#KOTd@l7#2VIpsj?nm_WfRZzJako`^uU%Nt3e;cU*y*|$7W zLm%fX#i_*HoUXu!NI$ey>BA<5HQB=|nRAwK!$L#n-Qz;~`zACig0PhAq#^5QS<8L2 zS3A+8%vbVMa7LOtTEM?55apt(DcWh#L}R^P2AY*c8B}Cx=6OFAdMPj1f>k3#^#+Hk z6uW1WJW&RlBRh*1DLb7mJ+KO>!t^t8hX1#_Wk`gjDio9)9IGbyCAGI4DJ~orK+YRv znjxRMtshZQHc$#Y-<-JOV6g^Cr@odj&Xw5B(FmI)*qJ9NHmIz_r{t)TxyB`L-%q5l ztzHgD;S6cw?7Atg*6E1!c6*gPRCb%t7D%z<(xm+K{%EJNiI2N0l8ud0Ch@_av_RW? zIr!nO4dL5466WslE6MsfMss7<)-S!e)2@r2o=7_W)OO`~CwklRWzHTfpB)_HYwgz=BzLhgZ9S<{nLBOwOIgJU=94uj6r!m>Xyn9>&xP+=5!zG_*yEoRgM0`aYts z^)&8(>z5C-QQ*o_s(8E4*?AX#S^0)aqB)OTyX>4BMy8h(cHjA8ji1PRlox@jB*1n? zDIfyDjzeg91Ao(;Q;KE@zei$}>EnrF6I}q&Xd=~&$WdDsyH0H7fJX|E+O~%LS*7^Q zYzZ4`pBdY{b7u72gZm6^5~O-57HwzwAz{)NvVaowo`X02tL3PpgLjwA`^i9F^vSpN zAqH3mRjG8VeJNHZ(1{%!XqC+)Z%D}58Qel{_weSEHoygT9pN@i zi=G;!Vj6XQk2tuJC>lza%ywz|`f7TIz*EN2Gdt!s199Dr4Tfd_%~fu8gXo~|ogt5Q zlEy_CXEe^BgsYM^o@L?s33WM14}7^T(kqohOX_iN@U?u;$l|rAvn{rwy>!yfZw13U zB@X9)qt&4;(C6dP?yRsoTMI!j-f1KC!<%~i1}u7yLXYn)(#a;Z6~r>hp~kfP));mi zcG%kdaB9H)z9M=H!f>kM->fTjRVOELNwh1amgKQT=I8J66kI)u_?0@$$~5f`u%;zl zC?pkr^p2Fe=J~WK%4ItSzKA+QHqJ@~m|Cduv=Q&-P8I5rQ-#G@bYH}YJr zUS(~(w|vKyU(T(*py}jTUp%I%{2!W!K(i$uvotcPjVddW z8_5HKY!oBCwGZcs-q`4Yt`Zk~>K?mcxg51wkZlX5e#B08I75F7#dgn5yf&Hrp`*%$ zQ;_Qg>TYRzBe$x=T(@WI9SC!ReSas9vDm(yslQjBJZde5z8GDU``r|N(MHcxNopGr z_}u39W_zwWDL*XYYt>#Xo!9kL#97|EAGyGBcRXtLTd59x%m=3i zL^9joWYA)HfL15l9%H?q`$mY27!<9$7GH(kxb%MV>`}hR4a?+*LH6aR{dzrX@?6X4 z3e`9L;cjqYb`cJmophbm(OX0b)!AFG?5`c#zLagzMW~o)?-!@e80lvk!p#&CD8u5_r&wp4O0zQ>y!k5U$h_K;rWGk=U)zX!#@Q%|9g*A zWx)qS1?fq6X<$mQTB$#3g;;5tHOYuAh;YKSBz%il3Ui6fPRv#v62SsrCdMRTav)Sg zTq1WOu&@v$Ey;@^+_!)cf|w_X<@RC>!=~+A1-65O0bOFYiH-)abINwZvFB;hJjL_$ z(9iScmUdMp2O$WW!520Hd0Q^Yj?DK%YgJD^ez$Z^?@9@Ab-=KgW@n8nC&88)TDC+E zlJM)L3r+ZJfZW_T$;Imq*#2<(j+FIk8ls7)WJ6CjUu#r5PoXxQs4b)mZza<8=v{o)VlLRM<9yw^0En#tXAj`Sylxvki{<1DPe^ zhjHwx^;c8tb?Vr$6ZB;$Ff$+3(*oinbwpN-#F)bTsXq@Sm?43MC#jQ~`F|twI=7oC zH4TJtu#;ngRA|Y~w5N=UfMZi?s0%ZmKUFTAye&6Y*y-%c1oD3yQ%IF2q2385Zl+=> zfz=o`Bedy|U;oxbyb^rB9ixG{Gb-{h$U0hVe`J;{ql!s_OJ_>>eoQn(G6h7+b^P48 zG<=Wg2;xGD-+d@UMZ!c;0>#3nws$9kIDkK13IfloGT@s14AY>&>>^#>`PT7GV$2Hp zN<{bN*ztlZu_%W=&3+=#3bE(mka6VoHEs~0BjZ$+=0`a@R$iaW)6>wp2w)=v2@|2d z%?34!+iOc5S@;AAC4hELWLH56RGxo4jw8MDMU0Wk2k_G}=Vo(>eRFo(g3@HjG|`H3 zm8b*dK=moM*oB<)*A$M9!!5o~4U``e)wxavm@O_R(`P|u%9^LGi(_%IF<6o;NLp*0 zKsfZ0#24GT8(G`i4UvoMh$^;kOhl?`0yNiyrC#HJH=tqOH^T_d<2Z+ zeN>Y9Zn!X4*DMCK^o75Zk2621bdmV7Rx@AX^alBG4%~;G_vUoxhfhFRlR&+3WwF^T zaL)8xPq|wCZoNT^>3J0K?e{J-kl+hu2rZI>CUv#-z&u@`hjeb+bBZ>bcciQVZ{SbW zez04s9oFEgc8Z+Kp{XFX`MVf-s&w9*dx7wLen(_@y34}Qz@&`$2+osqfxz4&d}{Ql z*g1ag00Gu+$C`0avds{Q65BfGsu9`_`dML*rX~hyWIe$T>CsPRoLIr%MTk3pJ^2zH1qub1MBzPG}PO;Wmav9w%F7?%l=xIf#LlP`! z_Nw;xBQY9anH5-c8A4mME}?{iewjz(Sq-29r{fV;Fc>fv%0!W@(+{={Xl-sJ6aMoc z)9Q+$bchoTGTyWU_oI19!)bD=IG&OImfy;VxNXoIO2hYEfO~MkE#IXTK(~?Z&!ae! zl8z{D&2PC$Q*OBC(rS~-*-GHNJ6AC$@eve>LB@Iq;jbBZj`wk4|LGogE||Ie=M5g= z9d`uYQ1^Sr_q2wmZE>w2WG)!F%^KiqyaDtIAct?}D~JP4shTJy5Bg+-(EA8aXaxbd~BKMtTf2iQ69jD1o* zZF9*S3!v-TdqwK$%&?91Sh2=e63;X0Lci@n7y3XOu2ofyL9^-I767eHESAq{m+@*r zbVDx!FQ|AjT;!bYsXv8ilQjy~Chiu&HNhFXt3R_6kMC8~ChEFqG@MWu#1Q1#=~#ix zrkHpJre_?#r=N0wv`-7cHHqU`phJX2M_^{H0~{VP79Dv{6YP)oA1&TSfKPEPZn2)G z9o{U1huZBLL;Tp_0OYw@+9z(jkrwIGdUrOhKJUbwy?WBt zlIK)*K0lQCY0qZ!$%1?3A#-S70F#YyUnmJF*`xx?aH5;gE5pe-15w)EB#nuf6B*c~ z8Z25NtY%6Wlb)bUA$w%HKs5$!Z*W?YKV-lE0@w^{4vw;J>=rn?u!rv$&eM+rpU6rc=j9>N2Op+C{D^mospMCjF2ZGhe4eADA#skp2EA26%p3Ex9wHW8l&Y@HX z$Qv)mHM}4*@M*#*ll5^hE9M^=q~eyWEai*P;4z<9ZYy!SlNE5nlc7gm;M&Q zKhKE4d*%A>^m0R?{N}y|i6i^k>^n4(wzKvlQeHq{l&JuFD~sTsdhs`(?lFK@Q{pU~ zb!M3c@*3IwN1RUOVjY5>uT+s-2QLWY z4T2>fiSn>>Fob+%B868-v9D@AfWr#M8eM6w#eAlhc#zk6jkLxGBGk`E3$!A@*am!R zy>29&ptYK6>cvP`b!syNp)Q$0UOW|-O@)8!?94GOYF_}+zlW%fCEl|Tep_zx05g6q z>tp47e-&R*hSNe{6{H!mL?+j$c^TXT{C&@T-xIaesNCl05 z9SLb@q&mSb)I{VXMaiWa3PWj=Ed!>*GwUe;^|uk=Pz$njNnfFY^MM>E?zqhf6^{}0 zx&~~dA5#}1ig~7HvOQ#;d9JZBeEQ+}-~v$at`m!(ai z$w(H&mWCC~;PQ1$%iuz3`>dWeb3_p}X>L2LK%2l59Tyc}4m0>9A!8rhoU3m>i2+hl zx?*qs*c^j}+WPs>&v1%1Ko8_ivAGIn@QK7A`hDz-Emkcgv2@wTbYhkiwX2l=xz*XG zaiNg+j4F-I>9v+LjosI-QECrtKjp&0T@xIMKVr+&)gyb4@b3y?2CA?=ooN zT#;rU86WLh(e@#mF*rk(NV-qSIZyr z$6!ZUmzD)%yO-ot`rw3rp6?*_l*@Z*IB0xn4|BGPWHNc-1ZUnNSMWmDh=EzWJRP`) zl%d%J613oXzh5;VY^XWJi{lB`f#u+ThvtP7 zq(HK<4>tw(=yzSBWtYO}XI`S1pMBe3!jFxBHIuwJ(@%zdQFi1Q_hU2eDuHqXte7Ki zOV55H2D6u#4oTfr7|u*3p75KF&jaLEDpxk!4*bhPc%mpfj)Us3XIG3 zIKMX^s^1wt8YK7Ky^UOG=w!o5e7W-<&c|fw2{;Q11vm@J{)@N3-p1U>!0~sKWHaL= zWV(0}1IIyt1p%=_-Fe5Kfzc71wg}`RDDntVZv;4!=&XXF-$48jS0Sc;eDy@Sg;+{A zFStc{dXT}kcIjMXb4F7MbX~2%i;UrBxm%qmLKb|2=?uPr00-$MEUIGR5+JG2l2Nq` zkM{{1RO_R)+8oQ6x&-^kCj)W8Z}TJjS*Wm4>hf+4#VJP)OBaDF%3pms7DclusBUw} z{ND#!*I6h85g6DzNvdAmnwWY{&+!KZM4DGzeHI?MR@+~|su0{y-5-nICz_MIT_#FE zm<5f3zlaKq!XyvY3H`9s&T};z!cK}G%;~!rpzk9-6L}4Rg7vXtKFsl}@sT#U#7)x- z7UWue5sa$R>N&b{J61&gvKcKlozH*;OjoDR+elkh|4bJ!_3AZNMOu?n9&|L>OTD78 z^i->ah_Mqc|Ev)KNDzfu1P3grBIM#%`QZqj5W{qu(HocQhjyS;UINoP`{J+DvV?|1 z_sw6Yr3z6%e7JKVDY<$P=M)dbk@~Yw9|2!Cw!io3%j92wTD!c^e9Vj+7VqXo3>u#= zv#M{HHJ=e$X5vQ>>ML?E8#UlmvJgTnb73{PSPTf*0)mcj6C z{KsfUbDK|F$E(k;ER%8HMdDi`=BfpZzP3cl5yJHu;v^o2FkHNk;cXc17tL8T!CsYI zfeZ6sw@;8ia|mY_AXjCS?kUfxdjDB28)~Tz1dGE|{VfBS9`0m2!m1yG?hR})er^pl4c@9Aq+|}ZlDaHL)K$O| z%9Jp-imI-Id0|(d5{v~w6mx)tUKfbuVD`xNt04Mry%M+jXzE>4(TBsx#&=@wT2Vh) z1yeEY&~17>0%P(eHP0HB^|7C+WJxQBTG$uyOWY@iDloRIb-Cf!p<{WQHR!422#F34 zG`v|#CJ^G}y9U*7jgTlD{D&y$Iv{6&PYG>{Ixg$pGk?lWrE#PJ8KunQC@}^6OP!|< zS;}p3to{S|uZz%kKe|;A0bL0XxPB&Q{J(9PyX`+Kr`k~r2}yP^ND{8!v7Q1&vtk& z2Y}l@J@{|2`oA%sxvM9i0V+8IXrZ4;tey)d;LZI70Kbim<4=WoTPZy=Yd|34v#$Kh zx|#YJ8s`J>W&jt#GcMpx84w2Z3ur-rK7gf-p5cE)=w1R2*|0mj12hvapuUWM0b~dG zMg9p8FmAZI@i{q~0@QuY44&mMUNXd7z>U58shA3o`p5eVLpq>+{(<3->DWuSFVZwC zxd50Uz(w~LxC4}bgag#q#NNokK@yNc+Q|Ap!u>Ddy+df>v;j@I12CDNN9do+0^n8p zMQs7X#+FVF0C5muGfN{r0|Nkql%BQT|K(DDNdR2pzM=_ea5+GO|J67`05AV92t@4l z0Qno0078PIHdaQGHZ~Scw!dzgqjK~3B7kf>BcP__&lLyU(cu3B^uLo%{j|Mb0NR)tkeT7Hcwp4O# z)yzu>cvG(d9~0a^)eZ;;%3ksk@F&1eEBje~ zW+-_s)&RgiweQc!otF>4%vbXKaOU41{!hw?|2`Ld3I8$&#WOsq>EG)1ANb!{N4z9@ zsU!bPG-~-bqCeIDzo^Q;gnucB{tRzm{ZH^Orphm2U+REA!*<*J6YQV83@&xoDl%#wnl5qcBqCcAF-vX5{30}(oJrnSH z{RY85hylK2dMOh2%oO1J8%)0?8TOL%rS8)+CsDv}aQ>4D)Jv+DLK)9gI^n-T^$)Tc zFPUD75qJm!Y-KBqj;JP4dV4 z`X{lGmn<)1IGz330}s}Jrjtf{(lnuuNHe5(ezA(pYa=1|Ff-LhPFK8 zyJh_b{yzu0yll6ZkpRzRjezyYivjyjW7QwO;@6X`m;2Apn2EK2!~7S}-*=;5*7K$B z`x(=!^?zgj(-`&ApZJXI09aDLXaT@<;CH=?fBOY5d|b~wBA@@p^K#nxr`)?i?SqTupI_PJ(A3cx`z~9mX_*)>L F{|7XC?P&l2 literal 0 HcmV?d00001 diff --git a/example/gradle/wrapper/gradle-wrapper.properties b/example/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9a4163a4 --- /dev/null +++ b/example/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/example/gradlew b/example/gradlew new file mode 100644 index 00000000..cccdd3d5 --- /dev/null +++ b/example/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/example/gradlew.bat b/example/gradlew.bat new file mode 100644 index 00000000..f9553162 --- /dev/null +++ b/example/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/example/local.properties b/example/local.properties new file mode 100644 index 00000000..c61b1095 --- /dev/null +++ b/example/local.properties @@ -0,0 +1,8 @@ +## This file must *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +#Sun Nov 18 18:49:43 EST 2018 +sdk.dir=C\:\\Users\\SORCERER\\AppData\\Local\\Android\\Sdk 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 6e193358..ca19f34f 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 @@ -151,40 +151,6 @@ public LDClient apply(List input) { }, MoreExecutors.directExecutor()); } - public static Set getEnvironmentNames() throws LaunchDarklyException { - if (instances == null) { - Timber.e("LDClient.getEnvironmentNames() was called before init()!"); - throw new LaunchDarklyException("LDClient.getEnvironmentNames() was called before init()!"); - } - return instances.keySet(); - } - - public static synchronized Future getForMobileKey(String keyName) { - SettableFuture settableFuture = SettableFuture.create(); - LDClient client = instances.get(keyName); - - if (client != null) { - settableFuture.set(client); - return settableFuture; - } else { - throw new NoSuchElementException(); - } - } - - private static LDClient getForMobileKey(String keyName, int startWaitSeconds) { - Future clientFuture = getForMobileKey(keyName); - - try { - return clientFuture.get(startWaitSeconds, TimeUnit.SECONDS); - } catch (InterruptedException | ExecutionException e) { - Timber.e(e, "Exception during secondary instance retrieval"); - } catch (TimeoutException e) { - Timber.w("Secondary instance was not retrieved within " + startWaitSeconds + " seconds. " + - "It could be taking longer than expected to start up"); - } - return instances.get(keyName); - } - private static boolean validateParameter(T parameter) { boolean parameterValid; try { @@ -234,6 +200,40 @@ public static LDClient get() throws LaunchDarklyException { return instances.get(LDConfig.primaryEnvironmentName); } + static Set getEnvironmentNames() throws LaunchDarklyException { + if (instances == null) { + Timber.e("LDClient.getEnvironmentNames() was called before init()!"); + throw new LaunchDarklyException("LDClient.getEnvironmentNames() was called before init()!"); + } + return instances.keySet(); + } + + public static synchronized Future getForMobileKey(String keyName) { + SettableFuture settableFuture = SettableFuture.create(); + LDClient client = instances.get(keyName); + + if (client != null) { + settableFuture.set(client); + return settableFuture; + } else { + throw new NoSuchElementException(); + } + } + + private static LDClient getForMobileKey(String keyName, int startWaitSeconds) { + Future clientFuture = getForMobileKey(keyName); + + try { + return clientFuture.get(startWaitSeconds, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException e) { + Timber.e(e, "Exception during secondary instance retrieval"); + } catch (TimeoutException e) { + Timber.w("Secondary instance was not retrieved within " + startWaitSeconds + " seconds. " + + "It could be taking longer than expected to start up"); + } + return instances.get(keyName); + } + @VisibleForTesting protected LDClient(final Application application, @NonNull final LDConfig config) { this(application, config, LDConfig.primaryEnvironmentName); 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 a399cf81..4f9e9ec3 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 @@ -60,7 +60,7 @@ public class LDConfig { private final boolean inlineUsersInEvents; - public LDConfig(Map mobileKeys, + LDConfig(Map mobileKeys, Uri baseUri, Uri eventsUri, Uri streamUri, @@ -100,13 +100,7 @@ public LDConfig(Map mobileKeys, } - public Request.Builder getRequestBuilder() { - return new Request.Builder() - .addHeader("Authorization", getMobileKey()) - .addHeader("User-Agent", USER_AGENT_HEADER_VALUE); - } - - public Request.Builder getRequestBuilderFor(String environment) { + Request.Builder getRequestBuilderFor(String environment) { if (environment == null) throw new IllegalArgumentException("null is not a valid environment"); From 9215b653df527f57f7a765f456b1889e36692058 Mon Sep 17 00:00:00 2001 From: torchhound Date: Wed, 12 Dec 2018 17:34:17 -0500 Subject: [PATCH 031/152] refactor(LDClient): changed isInternetConnected behavior for PR --- .../com/launchdarkly/android/LDClient.java | 19 +++++++++---------- .../java/com/launchdarkly/android/Util.java | 2 -- 2 files changed, 9 insertions(+), 12 deletions(-) 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 ca19f34f..7d101bac 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 @@ -3,6 +3,8 @@ import android.app.Application; import android.content.Context; import android.content.SharedPreferences; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; import android.support.annotation.NonNull; import com.google.common.annotations.VisibleForTesting; @@ -106,7 +108,11 @@ public static synchronized Future init(@NonNull Application applicatio Timber.plant(new Timber.DebugTree()); } - boolean internetConnected = isInternetConnected(application); + + ConnectivityManager cm = (ConnectivityManager) application.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + boolean deviceConnected = activeNetwork != null && activeNetwork.isConnectedOrConnecting(); + instances = new HashMap<>(); Map> updateFutures = new HashMap<>(); @@ -117,7 +123,7 @@ public static synchronized Future init(@NonNull Application applicatio instances.put(mobileKeys.getKey(), instance); - if (instance.isOffline() || !internetConnected) + if (instance.isOffline() || !deviceConnected) continue; instance.eventProcessor.start(); @@ -125,13 +131,6 @@ public static synchronized Future init(@NonNull Application applicatio instance.sendEvent(new IdentifyEvent(user)); } - final LDClient primaryInstance = instances.get(LDConfig.primaryEnvironmentName); - - if (!internetConnected) { - settableFuture.set(primaryInstance); - return settableFuture; - } - ArrayList> online = new ArrayList<>(); for (Map.Entry> entry : updateFutures.entrySet()) { @@ -146,7 +145,7 @@ public static synchronized Future init(@NonNull Application applicatio return Futures.transform(allFuture, new Function, LDClient>() { @Override public LDClient apply(List input) { - return primaryInstance; + return instances.get(LDConfig.primaryEnvironmentName); } }, MoreExecutors.directExecutor()); } 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 dfa613ea..bd0c6f99 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 @@ -27,7 +27,6 @@ static boolean isInternetConnected(Context context) { } static class LazySingleton { - private final Provider provider; private T instance; @@ -44,7 +43,6 @@ public T get() { } interface Provider { - T get(); } } From 5cce5b8f5dcb3a5c5cca4b878db085fbac4bff92 Mon Sep 17 00:00:00 2001 From: torchhound Date: Wed, 12 Dec 2018 18:06:11 -0500 Subject: [PATCH 032/152] refactor(LDClient): removed async getForMobileKeys and wait seconds version, replaced with method that returns requested instance --- .../com/launchdarkly/android/LDClient.java | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) 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 7d101bac..4e56dd94 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 @@ -207,29 +207,7 @@ static Set getEnvironmentNames() throws LaunchDarklyException { return instances.keySet(); } - public static synchronized Future getForMobileKey(String keyName) { - SettableFuture settableFuture = SettableFuture.create(); - LDClient client = instances.get(keyName); - - if (client != null) { - settableFuture.set(client); - return settableFuture; - } else { - throw new NoSuchElementException(); - } - } - - private static LDClient getForMobileKey(String keyName, int startWaitSeconds) { - Future clientFuture = getForMobileKey(keyName); - - try { - return clientFuture.get(startWaitSeconds, TimeUnit.SECONDS); - } catch (InterruptedException | ExecutionException e) { - Timber.e(e, "Exception during secondary instance retrieval"); - } catch (TimeoutException e) { - Timber.w("Secondary instance was not retrieved within " + startWaitSeconds + " seconds. " + - "It could be taking longer than expected to start up"); - } + public static LDClient getForMobileKey(String keyName) { return instances.get(keyName); } From f2d13436a0c292b9bb55c35608daf5191b3c1327 Mon Sep 17 00:00:00 2001 From: James Thacker Date: Fri, 11 Jan 2019 19:57:20 -0500 Subject: [PATCH 033/152] Bugfix/timber cleanup (#92) Relates to launchdarkly/android-client#60 Cleaned up timber logging messages to use string formatting rather than concatenation. Log messages should remain the same as before. Also replaced Log with Timber in the example app. --- example/build.gradle | 2 + .../launchdarkly/example/MainActivity.java | 26 +++++----- .../launchdarkly/android/EventProcessor.java | 2 +- .../android/HttpFeatureFlagFetcher.java | 6 +-- .../com/launchdarkly/android/LDClient.java | 50 +++++++------------ .../com/launchdarkly/android/LDConfig.java | 9 ++-- .../java/com/launchdarkly/android/LDUser.java | 5 +- .../android/StreamUpdateProcessor.java | 4 +- .../android/UserLocalSharePreferences.java | 33 ++++++------ .../com/launchdarkly/android/UserManager.java | 9 ++-- .../android/tls/SSLHandshakeInterceptor.java | 2 +- 11 files changed, 66 insertions(+), 82 deletions(-) diff --git a/example/build.gradle b/example/build.gradle index d1b28694..5c70916b 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -48,5 +48,7 @@ dependencies { // Comment the previous line and uncomment this one to depend on the published artifact: // compile 'com.launchdarkly:launchdarkly-android-client:1.0.1' + implementation 'com.jakewharton.timber:timber:4.7.0' + testImplementation 'junit:junit:4.12' } diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index d49ba1a0..8bee318d 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -3,7 +3,6 @@ import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v7.app.AppCompatActivity; -import android.util.Log; import android.view.View; import android.widget.ArrayAdapter; import android.widget.Button; @@ -25,9 +24,10 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import timber.log.Timber; + public class MainActivity extends AppCompatActivity { - private static final String TAG = "MainActivity"; private LDClient ldClient; @Override @@ -54,7 +54,7 @@ protected void onCreate(Bundle savedInstanceState) { try { ldClient = initFuture.get(10, TimeUnit.SECONDS); } catch (InterruptedException | ExecutionException | TimeoutException e) { - Log.e(TAG, "Exception when awaiting LaunchDarkly Client initialization", e); + Timber.e(e, "Exception when awaiting LaunchDarkly Client initialization"); } } @@ -67,7 +67,7 @@ public void call() { try { ldClient.close(); } catch (IOException e) { - Log.e(TAG, "Exception when closing LaunchDarkly Client", e); + Timber.e(e, "Exception when closing LaunchDarkly Client"); } } }); @@ -78,7 +78,7 @@ private void setupFlushButton() { flushButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - Log.i(TAG, "flush onClick"); + Timber.i("flush onClick"); MainActivity.this.doSafeClientAction(new LDClientFunction() { @Override public void call() { @@ -116,7 +116,7 @@ private void setupTrackButton() { trackButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - Log.i(TAG, "track onClick"); + Timber.i("track onClick"); MainActivity.this.doSafeClientAction(new LDClientFunction() { @Override public void call() { @@ -132,7 +132,7 @@ private void setupIdentifyButton() { identify.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - Log.i(TAG, "identify onClick"); + Timber.i("identify onClick"); String userKey = ((EditText) MainActivity.this.findViewById(R.id.userKey_editText)).getText().toString(); final LDUser updatedUser = new LDUser.Builder(userKey).build(); MainActivity.this.doSafeClientAction(new LDClientFunction() { @@ -180,7 +180,7 @@ private void setupEval() { evalButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - Log.i(TAG, "eval onClick"); + Timber.i("eval onClick"); final String flagKey = ((EditText) MainActivity.this.findViewById(R.id.feature_flag_key)).getText().toString(); String type = spinner.getSelectedItem().toString(); @@ -195,7 +195,7 @@ public String get() { } }); logResult = result == null ? "no result" : result; - Log.i(TAG, logResult); + Timber.i(logResult); ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); MainActivity.this.doSafeClientAction(new LDClientFunction() { @Override @@ -218,7 +218,7 @@ public String get() { } }); logResult = result == null ? "no result" : result; - Log.i(TAG, logResult); + Timber.i(logResult); ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); break; case "Integer": @@ -229,7 +229,7 @@ public String get() { } }); logResult = result == null ? "no result" : result; - Log.i(TAG, logResult); + Timber.i(logResult); ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); break; case "Float": @@ -240,7 +240,7 @@ public String get() { } }); logResult = result == null ? "no result" : result; - Log.i(TAG, logResult); + Timber.i(logResult); ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); break; case "Json": @@ -251,7 +251,7 @@ public String get() { } }); logResult = result == null ? "no result" : result; - Log.i(TAG, logResult); + Timber.i(logResult); ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); break; } 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 938dc011..02d87002 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 @@ -145,7 +145,7 @@ private void postEvents(List events) { .addHeader("X-LaunchDarkly-Event-Schema", "3") .build(); - Timber.d("Posting " + events.size() + " event(s) to " + request.url()); + Timber.d("Posting %s event(s) to %s", events.size(), request.url()); Response response = null; try { 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 99ad716a..7e6c9664 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 @@ -41,7 +41,7 @@ class HttpFeatureFlagFetcher implements FeatureFlagFetcher { private final Context context; private final OkHttpClient client; - private volatile boolean isOffline = false; + private volatile boolean isOffline; static HttpFeatureFlagFetcher init(Context context, LDConfig config) { instance = new HttpFeatureFlagFetcher(context, config); @@ -109,7 +109,7 @@ public void onResponse(Call call, final Response response) throws IOException { + request.url() + " with body: " + body); } Timber.d(body); - Timber.d("Cache hit count: " + client.cache().hitCount() + " Cache network Count: " + client.cache().networkCount()); + Timber.d("Cache hit count: %s Cache network Count: %s", client.cache().hitCount(), client.cache().networkCount()); Timber.d("Cache response: %s", response.cacheResponse()); Timber.d("Network response: %s", response.networkResponse()); @@ -117,7 +117,7 @@ public void onResponse(Call call, final Response response) throws IOException { JsonObject jsonObject = parser.parse(body).getAsJsonObject(); doneFuture.set(jsonObject); } catch (Exception e) { - Timber.e(e, "Exception when handling response for url: " + request.url() + " with body: " + body); + Timber.e(e, "Exception when handling response for url: %s with body: %s", request.url(), body); doneFuture.setException(e); } finally { if (response != null) { 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 f41c21a4..e3b52b36 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 @@ -148,15 +148,14 @@ private static boolean validateParameter(T parameter) { * @return */ public static synchronized LDClient init(Application application, LDConfig config, LDUser user, int startWaitSeconds) { - Timber.i("Initializing Client and waiting up to " + startWaitSeconds + " for initialization to complete"); + Timber.i("Initializing Client and waiting up to %s for initialization to complete", startWaitSeconds); Future initFuture = init(application, config, user); try { return initFuture.get(startWaitSeconds, TimeUnit.SECONDS); } catch (InterruptedException | ExecutionException e) { Timber.e(e, "Exception during Client initialization"); } catch (TimeoutException e) { - Timber.w("Client did not successfully initialize within " + startWaitSeconds + " seconds. " + - "It could be taking longer than expected to start up"); + Timber.w("Client did not successfully initialize within %s seconds. It could be taking longer than expected to start up", startWaitSeconds); } return instance; } @@ -191,7 +190,7 @@ protected LDClient(final Application application, @NonNull final LDConfig config } instanceId = instanceIdSharedPrefs.getString(INSTANCE_ID_KEY, instanceId); - Timber.i("Using instance id: " + instanceId); + Timber.i("Using instance id: %s", instanceId); this.fetcher = HttpFeatureFlagFetcher.init(application, config); this.userManager = UserManager.init(application, fetcher); @@ -326,11 +325,9 @@ public Boolean boolVariation(String flagKey, Boolean 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: " - + flagKey + " Returning fallback: " + fallback); + 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: " - + flagKey + " Returning fallback: " + fallback); + 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); @@ -347,7 +344,7 @@ public Boolean boolVariation(String flagKey, Boolean fallback) { updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); } - Timber.d("boolVariation: returning variation: " + result + " flagKey: " + flagKey + " user key: " + userManager.getCurrentUser().getKeyAsString()); + Timber.d("boolVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -369,11 +366,9 @@ public Integer intVariation(String flagKey, Integer 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: " - + flagKey + " Returning fallback: " + fallback); + 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: " - + flagKey + " Returning fallback: " + fallback); + 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); @@ -390,7 +385,7 @@ public Integer intVariation(String flagKey, Integer fallback) { updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); } - Timber.d("intVariation: returning variation: " + result + " flagKey: " + flagKey + " user key: " + userManager.getCurrentUser().getKeyAsString()); + Timber.d("intVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -412,11 +407,9 @@ public Float floatVariation(String flagKey, Float 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: " - + flagKey + " Returning fallback: " + fallback); + 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: " - + flagKey + " Returning fallback: " + fallback); + 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); @@ -433,7 +426,7 @@ public Float floatVariation(String flagKey, Float fallback) { updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); } - Timber.d("floatVariation: returning variation: " + result + " flagKey: " + flagKey + " user key: " + userManager.getCurrentUser().getKeyAsString()); + Timber.d("floatVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -455,11 +448,9 @@ public String stringVariation(String flagKey, String 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: " - + flagKey + " Returning fallback: " + fallback); + 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: " - + flagKey + " Returning fallback: " + fallback); + 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); @@ -476,7 +467,7 @@ public String stringVariation(String flagKey, String fallback) { updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); } - Timber.d("stringVariation: returning variation: " + result + " flagKey: " + flagKey + " user key: " + userManager.getCurrentUser().getKeyAsString()); + Timber.d("stringVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -501,20 +492,17 @@ public JsonElement jsonVariation(String flagKey, JsonElement fallback) { result = new JsonParser().parse(stringResult); } } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get json (string) flag that exists as another type for key: " - + flagKey + " Returning fallback: " + fallback); + 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: " - + flagKey + " Returning fallback: " + fallback); + 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: " + - flagKey + " Returning fallback: " + fallback); + 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: " + result + " flagKey: " + flagKey + " user key: " + userManager.getCurrentUser().getKeyAsString()); + Timber.d("jsonVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } 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 bbbd8f55..7f5a9015 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 @@ -373,14 +373,12 @@ public LDConfig.Builder setInlineUsersInEvents(boolean inlineUsersInEvents) { public LDConfig build() { if (!stream) { if (pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { - Timber.w("setPollingIntervalMillis: " + pollingIntervalMillis - + " was set below the allowed minimum of: " + MIN_POLLING_INTERVAL_MILLIS + ". Ignoring and using minimum value."); + Timber.w("setPollingIntervalMillis: %s was set below the allowed minimum of: %s. Ignoring and using minimum value.", pollingIntervalMillis, MIN_POLLING_INTERVAL_MILLIS); pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; } if (!disableBackgroundUpdating && backgroundPollingIntervalMillis < pollingIntervalMillis) { - Timber.w("BackgroundPollingIntervalMillis: " + backgroundPollingIntervalMillis + - " was set below the foreground polling interval: " + pollingIntervalMillis + ". Ignoring and using minimum value for background polling."); + Timber.w("BackgroundPollingIntervalMillis: %s was set below the foreground polling interval: %s. Ignoring and using minimum value for background polling.", backgroundPollingIntervalMillis, pollingIntervalMillis); backgroundPollingIntervalMillis = MIN_BACKGROUND_POLLING_INTERVAL_MILLIS; } @@ -392,8 +390,7 @@ public LDConfig build() { if (!disableBackgroundUpdating) { if (backgroundPollingIntervalMillis < MIN_BACKGROUND_POLLING_INTERVAL_MILLIS) { - Timber.w("BackgroundPollingIntervalMillis: " + backgroundPollingIntervalMillis + - " was set below the minimum allowed: " + MIN_BACKGROUND_POLLING_INTERVAL_MILLIS + ". Ignoring and using minimum value."); + Timber.w("BackgroundPollingIntervalMillis: %s was set below the minimum allowed: %s. Ignoring and using minimum value.", backgroundPollingIntervalMillis, MIN_BACKGROUND_POLLING_INTERVAL_MILLIS); backgroundPollingIntervalMillis = MIN_BACKGROUND_POLLING_INTERVAL_MILLIS; } } 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 02127581..f7396669 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 @@ -93,8 +93,7 @@ public class LDUser { protected LDUser(Builder builder) { if (builder.key == null || builder.key.equals("")) { - Timber.w("User was created with null/empty key. " + - "Using device-unique anonymous user key: " + LDClient.getInstanceId()); + Timber.w("User was created with null/empty key. Using device-unique anonymous user key: %s", LDClient.getInstanceId()); this.key = new JsonPrimitive(LDClient.getInstanceId()); this.anonymous = new JsonPrimitive(true); } else { @@ -684,7 +683,7 @@ private Builder customNumber(Map map, String k, List= 400 && code < 500) { - Timber.e("Encountered non-retriable error: " + code + ". Aborting connection to stream. Verify correct Mobile Key and Stream URI"); + Timber.e("Encountered non-retriable error: %s. Aborting connection to stream. Verify correct Mobile Key and Stream URI", code); running = false; if (!initialized.getAndSet(true)) { initFuture.setException(t); 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 index 8154d54d..67055cb9 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java @@ -64,8 +64,7 @@ void setCurrentUser(LDUser user) { while (usersSharedPrefs.getAll().size() > MAX_USERS) { List allUsers = getAllUsers(); String removed = allUsers.get(0); - Timber.d("Exceeded max # of users: [" + MAX_USERS + "] Removing user: [" + removed - + "] [" + removed + "]"); + Timber.d("Exceeded max # of users: [%s] Removing user: [%s]", MAX_USERS, removed); deleteSharedPreferences(removed); usersSharedPrefs.edit() .remove(removed) @@ -75,7 +74,7 @@ void setCurrentUser(LDUser user) { } private SharedPreferences loadSharedPrefsForUser(String user) { - Timber.d("Using SharedPreferences key: [" + sharedPrefsKeyForUser(user) + "]"); + Timber.d("Using SharedPreferences key: [%s]", sharedPrefsKeyForUser(user)); return application.getSharedPreferences(sharedPrefsKeyForUser(user), Context.MODE_PRIVATE); } @@ -133,7 +132,7 @@ private void deleteSharedPreferences(String userKey) { private SharedPreferences loadSharedPrefsForActiveUser() { String sharedPrefsKey = LDConfig.SHARED_PREFS_BASE_KEY + "active"; - Timber.d("Using SharedPreferences key for active user: [" + sharedPrefsKey + "]"); + Timber.d("Using SharedPreferences key for active user: [%s]", sharedPrefsKey); return application.getSharedPreferences(sharedPrefsKey, Context.MODE_PRIVATE); } @@ -148,14 +147,14 @@ void registerListener(final String key, final FeatureFlagChangeListener listener @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String s) { if (s.equals(key)) { - Timber.d("Found changed flag: [" + 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: [" + listeners.size() + "]"); + Timber.d("Added listener. Total count: [%s]", listeners.size()); } activeUserSharedPrefs.registerOnSharedPreferenceChangeListener(sharedPrefsListener); @@ -167,7 +166,7 @@ void unRegisterListener(String key, FeatureFlagChangeListener listener) { while (it.hasNext()) { Pair pair = it.next(); if (pair.first.equals(listener)) { - Timber.d("Removing listener for key: [" + key + "]"); + Timber.d("Removing listener for key: [%s]", key); activeUserSharedPrefs.unregisterOnSharedPreferenceChangeListener(pair.second); it.remove(); } @@ -198,24 +197,24 @@ void syncCurrentUserToActiveUser() { for (Map.Entry entry : current.entrySet()) { Object v = entry.getValue(); String key = entry.getKey(); - Timber.d("key: [" + key + "] CurrentUser value: [" + v + "] ActiveUser value: [" + active.get(key) + "]"); + 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: [" + key + "] with value: [" + 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: [" + key + "] with value: [" + 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: [" + key + "] with value: [" + 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: [" + key + "] and value: [" + v + "]"); + Timber.w("Found some unknown feature flag type for key: [%s] with value: [%s]", key, v); } } @@ -223,7 +222,7 @@ void syncCurrentUserToActiveUser() { // 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: [" + key + "]"); + Timber.d("Deleting value and listeners for key: [%s]", key); activeEditor.remove(key); synchronized (listeners) { listeners.removeAll(key); @@ -239,15 +238,15 @@ void logCurrentUserFlags() { if (all.size() == 0) { Timber.d("found zero saved feature flags"); } else { - Timber.d("Found " + all.size() + " feature flags:"); + Timber.d("Found %s feature flags:", all.size()); for (Map.Entry kv : all.entrySet()) { - Timber.d("\tKey: [" + kv.getKey() + "] value: [" + kv.getValue() + "]"); + Timber.d("\tKey: [%s] value: [%s]", kv.getKey(), kv.getValue()); } } } void deleteCurrentUserFlag(String flagKey) { - Timber.d("Request to delete key: [" + flagKey + "]"); + Timber.d("Request to delete key: [%s]", flagKey); removeCurrentUserFlag(flagKey); @@ -264,7 +263,7 @@ private void removeCurrentUserFlag(String flagKey) { if (key.equals(flagKey)) { editor.remove(flagKey); - Timber.d("Deleting key: [" + key + "] CurrentUser value: [" + v + "]"); + Timber.d("Deleting key: [%s] CurrentUser value: [%s]", key, v); } } 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 8a18662c..dc807c3d 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 @@ -118,7 +118,7 @@ SummaryEventSharedPreferences getSummaryEventSharedPreferences() { @SuppressWarnings("JavaDoc") void setCurrentUser(final LDUser user) { String userBase64 = user.getAsUrlSafeBase64(); - Timber.d("Setting current user to: [" + userBase64 + "] [" + userBase64ToJson(userBase64) + "]"); + Timber.d("Setting current user to: [%s] [%s]", userBase64, userBase64ToJson(userBase64)); currentUser = user; userLocalSharedPreferences.setCurrentUser(user); } @@ -136,8 +136,7 @@ public void onSuccess(JsonObject result) { @Override public void onFailure(@NonNull Throwable t) { if (Util.isInternetConnected(application)) { - Timber.e(t, "Error when attempting to set user: [" + currentUser.getAsUrlSafeBase64() - + "] [" + userBase64ToJson(currentUser.getAsUrlSafeBase64()) + "]"); + Timber.e(t, "Error when attempting to set user: [%s] [%s]", currentUser.getAsUrlSafeBase64(), userBase64ToJson(currentUser.getAsUrlSafeBase64())); } syncCurrentUserToActiveUserAndLog(); } @@ -310,7 +309,7 @@ private UserLocalSharedPreferences.SharedPreferencesEntries getSharedPreferences UserLocalSharedPreferences.SharedPreferencesEntry sharedPreferencesEntry = getSharedPreferencesEntry(flagResponse); if (sharedPreferencesEntry == null) { - Timber.w("Found some unknown feature flag type for key: [" + key + "] value: [" + v.toString() + "]"); + Timber.w("Found some unknown feature flag type for key: [%s] value: [%s]", key, v.toString()); } else { sharedPreferencesEntryList.add(sharedPreferencesEntry); } @@ -331,7 +330,7 @@ private UserLocalSharedPreferences.SharedPreferencesEntries getSharedPreferences UserLocalSharedPreferences.SharedPreferencesEntry sharedPreferencesEntry = getSharedPreferencesEntry(flagResponse); if (sharedPreferencesEntry == null) { - Timber.w("Found some unknown feature flag type for key: [" + key + "] value: [" + v.toString() + "]"); + Timber.w("Found some unknown feature flag type for key: [%s] value: [%s]", key, v.toString()); } else { sharedPreferencesEntryList.add(sharedPreferencesEntry); } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java index 84eabce0..406cd497 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/SSLHandshakeInterceptor.java @@ -28,7 +28,7 @@ private void printTlsAndCipherSuiteInfo(Response response) { if (handshake != null) { final CipherSuite cipherSuite = handshake.cipherSuite(); final TlsVersion tlsVersion = handshake.tlsVersion(); - Timber.v("TLS: " + tlsVersion + ", CipherSuite: " + cipherSuite); + Timber.v("TLS: %s, CipherSuite: %s", tlsVersion, cipherSuite); } } } From cac1e279a1920298103a093cb9d0057b0fee3745 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Mon, 14 Jan 2019 15:01:07 -0800 Subject: [PATCH 034/152] Fix crash when example app is backgrounded twice. --- .../com/launchdarkly/example/MainActivity.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index 8bee318d..37d668e3 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -18,7 +18,6 @@ import com.launchdarkly.android.LDConfig; import com.launchdarkly.android.LDUser; -import java.io.IOException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -58,21 +57,6 @@ protected void onCreate(Bundle savedInstanceState) { } } - @Override - public void onStop() { - super.onStop(); - doSafeClientAction(new LDClientFunction() { - @Override - public void call() { - try { - ldClient.close(); - } catch (IOException e) { - Timber.e(e, "Exception when closing LaunchDarkly Client"); - } - } - }); - } - private void setupFlushButton() { Button flushButton = findViewById(R.id.flush_button); flushButton.setOnClickListener(new View.OnClickListener() { From cd02ea3fcd907fc2f0acd4eead8551eed7fd392c Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 22 Jan 2019 08:10:57 -0800 Subject: [PATCH 035/152] Add security provider update mechanism using Google Play Services to attempt a provider update when TLSv1.2 is not available. --- launchdarkly-android-client/build.gradle | 3 ++ .../com/launchdarkly/android/LDClient.java | 34 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/launchdarkly-android-client/build.gradle b/launchdarkly-android-client/build.gradle index 9153bb30..b3169997 100644 --- a/launchdarkly-android-client/build.gradle +++ b/launchdarkly-android-client/build.gradle @@ -71,6 +71,9 @@ dependencies { implementation "com.launchdarkly:okhttp-eventsource:$eventsourceVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation 'com.jakewharton.timber:timber:4.7.0' + // Explicitly old version of Google Play Services to allow compatibility without update for + // older phones + implementation 'com.google.android.gms:play-services-base:6.5.87' androidTestImplementation "com.android.support:appcompat-v7:$supportVersion" androidTestImplementation "com.android.support:support-annotations:$supportVersion" 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 957deeb2..49dee6d4 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,6 +9,8 @@ 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; @@ -23,10 +25,13 @@ import com.google.gson.JsonPrimitive; import com.google.gson.JsonSyntaxException; import com.launchdarkly.android.response.SummaryEventSharedPreferences; +import com.google.android.gms.security.ProviderInstaller; import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.security.NoSuchAlgorithmException; +import java.security.Security; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -38,6 +43,8 @@ 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.isInternetConnected; @@ -99,10 +106,9 @@ public static synchronized Future init(@NonNull Application applicatio return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid user")); } - SettableFuture settableFuture = SettableFuture.create(); - if (instances != null) { Timber.w("LDClient.init() was called more than once! returning primary instance."); + SettableFuture settableFuture = SettableFuture.create(); settableFuture.set(instances.get(LDConfig.primaryEnvironmentName)); return settableFuture; } @@ -110,6 +116,20 @@ public static synchronized Future init(@NonNull Application applicatio Timber.plant(new Timber.DebugTree()); } + Security.removeProvider("AndroidOpenSSL"); + + 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."); + } + } ConnectivityManager cm = (ConnectivityManager) application.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); @@ -143,7 +163,6 @@ public static synchronized Future init(@NonNull Application applicatio ListenableFuture> allFuture = Futures.allAsList(online); - // Transform initFuture so its result is the instance: return Futures.transform(allFuture, new Function, LDClient>() { @Override public LDClient apply(List input) { @@ -208,7 +227,14 @@ static Set getEnvironmentNames() throws LaunchDarklyException { return instances.keySet(); } - public static LDClient getForMobileKey(String keyName) { + public static LDClient getForMobileKey(String keyName) throws LaunchDarklyException { + if (instances == null) { + Timber.e("LDClient.getForMobileKey() was called before init()!"); + throw new LaunchDarklyException("LDClient.getForMobileKey() was called before init()!"); + } + if (!(instances.containsKey(keyName))) { + throw new LaunchDarklyException("LDClient.getForMobileKey() called with invalid keyName"); + } return instances.get(keyName); } From 6b135f498a394cedb9c5f4ecb6d702a37a73c42d Mon Sep 17 00:00:00 2001 From: Joe Cieslik Date: Tue, 22 Jan 2019 08:36:55 -0800 Subject: [PATCH 036/152] Shared Preferences Fix for Multi Environment (#94) * fix(SharedPreferences): added more SharedPreferences first time migration and differentiated SharedPreferences by mobile key * fix(UserLocalSharePreference.java): added missing mobileKey additions to getSharedPreferences, cleaned up debugging code --- .../com/launchdarkly/android/LDClient.java | 83 +++++++++++-------- .../android/StreamUpdateProcessor.java | 4 +- .../android/UserLocalSharePreferences.java | 12 +-- .../com/launchdarkly/android/UserManager.java | 13 ++- 4 files changed, 65 insertions(+), 47 deletions(-) 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 49dee6d4..294a305b 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 @@ -57,7 +57,6 @@ public class LDClient implements LDClientInterface, Closeable { private static final String INSTANCE_ID_KEY = "instanceId"; private static final String INSTANCE_CLEAR_KEY = "clear"; - private static int instanceClearCounter = 0; // Upon client init will get set to a Unique id per installation used when creating anonymous users private static String instanceId = "UNKNOWN_ANDROID"; private static Map instances = null; @@ -139,6 +138,16 @@ public static synchronized Future init(@NonNull Application applicatio Map> updateFutures = new HashMap<>(); + for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { + String mobileKey = mobileKeys.getKey(); + multiEnvironmentSharedPreferencesMigration(application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE), + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "id", Context.MODE_PRIVATE), + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "users", Context.MODE_PRIVATE), + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "users", Context.MODE_PRIVATE), + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "version", Context.MODE_PRIVATE), + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "version", Context.MODE_PRIVATE)); + } + for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { final LDClient instance = new LDClient(application, config, mobileKeys.getKey()); instance.userManager.setCurrentUser(user); @@ -250,38 +259,8 @@ protected LDClient(final Application application, @NonNull final LDConfig config this.isOffline = config.isOffline(); this.application = new WeakReference<>(application); - SharedPreferences instanceIdSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE); SharedPreferences mobileKeySharedPrefs = - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + config.getMobileKeys().get(environmentName), Context.MODE_PRIVATE); - - if (!instanceIdSharedPrefs.contains(INSTANCE_CLEAR_KEY)) { - SharedPreferences.Editor editor = mobileKeySharedPrefs.edit(); - - for (Map.Entry entry : instanceIdSharedPrefs.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)); - editor.apply(); - } - - if (instanceClearCounter > config.getMobileKeys().size()) { //SharedPreferences will only be cleared if all environments have a copy - instanceIdSharedPrefs.edit().clear().commit(); - instanceIdSharedPrefs.edit().putString(INSTANCE_CLEAR_KEY, INSTANCE_CLEAR_KEY).apply(); - } else { - instanceClearCounter++; - } - - } + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + config.getMobileKeys().get(environmentName) + "id", Context.MODE_PRIVATE); if (!mobileKeySharedPrefs.contains(INSTANCE_ID_KEY)) { String uuid = UUID.randomUUID().toString(); @@ -295,7 +274,7 @@ else if (value instanceof String) Timber.i("Using instance id: %s", instanceId); this.fetcher = HttpFeatureFlagFetcher.newInstance(application, config, environmentName); - this.userManager = UserManager.newInstance(application, fetcher); + this.userManager = UserManager.newInstance(application, fetcher, config.getMobileKeys().get(environmentName)); Foreground foreground = Foreground.get(application); Foreground.Listener foregroundListener = new Foreground.Listener() { @Override @@ -338,6 +317,44 @@ public void run() { } } + private static void multiEnvironmentSharedPreferencesMigration(SharedPreferences instanceIdSharedPrefs, SharedPreferences newInstanceIdSharedPrefs, + SharedPreferences userSharedPrefs, SharedPreferences newUserSharedPrefs, + SharedPreferences userFlagSharedPrefs, SharedPreferences newUserFlagSharedPrefs) { + checkAndClearSharedPreferences(instanceIdSharedPrefs, newInstanceIdSharedPrefs); + checkAndClearSharedPreferences(userFlagSharedPrefs, newUserFlagSharedPrefs); + checkAndClearSharedPreferences(userSharedPrefs, newUserSharedPrefs); + } + + private static void checkAndClearSharedPreferences(SharedPreferences oldPrefs, SharedPreferences newPrefs) { + if (!oldPrefs.contains(INSTANCE_CLEAR_KEY)) { + copySharedPreferences(oldPrefs, newPrefs); + + oldPrefs.edit().clear().commit(); + oldPrefs.edit().putString(INSTANCE_CLEAR_KEY, INSTANCE_CLEAR_KEY).apply(); + } + } + + private static void 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)); + editor.apply(); + } + } + /** * Tracks that a user performed an event. * 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 57251c61..df29e3fd 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 @@ -67,12 +67,12 @@ public synchronized ListenableFuture start() { EventHandler handler = new EventHandler() { @Override - public void onOpen() throws Exception { + public void onOpen() { Timber.i("Started LaunchDarkly EventStream"); } @Override - public void onClosed() throws Exception { + public void onClosed() { Timber.i("Closed LaunchDarkly EventStream"); } 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 index 3d1c90ff..72f9bc13 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java @@ -42,9 +42,12 @@ class UserLocalSharedPreferences { // The current user- we'll always fetch this user from the response we get from the api private SharedPreferences currentUserSharedPrefs; - UserLocalSharedPreferences(Application application) { + private String mobileKey; + + UserLocalSharedPreferences(Application application, String mobileKey) { this.application = application; - this.usersSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "users", Context.MODE_PRIVATE); + 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); @@ -79,7 +82,7 @@ private SharedPreferences loadSharedPrefsForUser(String user) { } private String sharedPrefsKeyForUser(String user) { - return LDConfig.SHARED_PREFS_BASE_KEY + user; + return LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + user; } // Gets all users sorted by creation time (oldest first) @@ -131,7 +134,7 @@ private void deleteSharedPreferences(String userKey) { } private SharedPreferences loadSharedPrefsForActiveUser() { - String sharedPrefsKey = LDConfig.SHARED_PREFS_BASE_KEY + "active"; + 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); } @@ -188,7 +191,6 @@ void saveCurrentUserFlags(SharedPreferencesEntries sharedPreferencesEntries) { * active user as well as their listeners. */ void syncCurrentUserToActiveUser() { - SharedPreferences.Editor activeEditor = activeUserSharedPrefs.edit(); Map active = activeUserSharedPrefs.getAll(); Map current = currentUserSharedPrefs.getAll(); 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 f80df097..628ba432 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 @@ -59,16 +59,16 @@ class UserManager { private final ExecutorService executor; - static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher) { - return new UserManager(application, fetcher); + static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher, String mobileKey) { + return new UserManager(application, fetcher, mobileKey); } - UserManager(Application application, FeatureFlagFetcher fetcher) { + UserManager(Application application, FeatureFlagFetcher fetcher, String mobileKey) { this.application = application; this.fetcher = fetcher; - this.userLocalSharedPreferences = new UserLocalSharedPreferences(application); - this.flagResponseSharedPreferences = new UserFlagResponseSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + "version"); - this.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + "summaryevents"); + this.userLocalSharedPreferences = new UserLocalSharedPreferences(application, mobileKey); + this.flagResponseSharedPreferences = new UserFlagResponseSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "version"); + this.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "summaryevents"); jsonParser = new Util.LazySingleton<>(new Util.Provider() { @Override @@ -95,7 +95,6 @@ SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return summaryEventSharedPreferences; } - /** * Sets the current user. If there are more than MAX_USERS stored in shared preferences, * the oldest one is deleted. From 55eef133e533c8f5a5e5410679ec40f4eacd225f Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 22 Jan 2019 09:40:21 -0800 Subject: [PATCH 037/152] Fix edge cases in how multi-environment handles connection changes. --- .../android/ConnectivityReceiver.java | 20 +++++++----- .../launchdarkly/android/EventProcessor.java | 6 ++-- .../android/HttpFeatureFlagFetcher.java | 18 +++++------ .../com/launchdarkly/android/LDClient.java | 16 +++++----- .../launchdarkly/android/PollingUpdater.java | 10 ++++-- .../android/StreamUpdateProcessor.java | 2 +- .../com/launchdarkly/android/UserManager.java | 10 +++--- .../java/com/launchdarkly/android/Util.java | 31 +++++++++++++++++-- 8 files changed, 76 insertions(+), 37 deletions(-) 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 1cd75cd7..8bd54470 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 @@ -17,12 +17,14 @@ public void onReceive(Context context, Intent intent) { if (isInternetConnected(context)) { Timber.d("Connected to the internet"); try { - LDClient ldClient = LDClient.get(); - if (!ldClient.isOffline()) { - if (Foreground.get(context).isForeground()) { - ldClient.startForegroundUpdating(); - } else if (!ldClient.isDisableBackgroundPolling()){ - PollingUpdater.startBackgroundPolling(context); + for (String environmentName : LDClient.getEnvironmentNames()) { + LDClient ldClient = LDClient.getForMobileKey(environmentName); + if (!ldClient.isOffline()) { + if (Foreground.get(context).isForeground()) { + ldClient.startForegroundUpdating(); + } else if (!ldClient.isDisableBackgroundPolling()) { + PollingUpdater.startBackgroundPolling(context); + } } } } catch (LaunchDarklyException e) { @@ -31,8 +33,10 @@ public void onReceive(Context context, Intent intent) { } else { Timber.d("Not Connected to the internet"); try { - LDClient ldClient = LDClient.get(); - ldClient.stopForegroundUpdating(); + for (String environmentName : LDClient.getEnvironmentNames()) { + LDClient ldClient = LDClient.getForMobileKey(environmentName); + ldClient.stopForegroundUpdating(); + } } catch (LaunchDarklyException e) { Timber.e(e, "Tried to stop foreground updating, but LDClient has not yet been initialized."); } 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 704d7a5b..62e08398 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 @@ -33,7 +33,7 @@ import timber.log.Timber; import static com.launchdarkly.android.LDConfig.JSON; -import static com.launchdarkly.android.Util.isInternetConnected; +import static com.launchdarkly.android.Util.isClientConnected; class EventProcessor implements Closeable { private final BlockingQueue queue; @@ -123,7 +123,7 @@ public void run() { } public synchronized void flush() { - if (isInternetConnected(context)) { + if (isClientConnected(context, environmentName)) { List events = new ArrayList<>(queue.size() + 1); queue.drainTo(events); if (summaryEvent != null) { @@ -159,7 +159,7 @@ private void postEvents(List events) { SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US); try { Date date = sdf.parse(dateString); - currentTimeMs = date.getTime(); + currentTimeMs = date.getTime(); } catch (ParseException pe) { Timber.e(pe, "Failed to parse date header"); } 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 5d91407e..296205ef 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 @@ -29,26 +29,26 @@ import timber.log.Timber; import static com.launchdarkly.android.LDConfig.GSON; -import static com.launchdarkly.android.Util.isInternetConnected; +import static com.launchdarkly.android.Util.isClientConnected; class HttpFeatureFlagFetcher implements FeatureFlagFetcher { private static final int MAX_CACHE_SIZE_BYTES = 500_000; private final LDConfig config; - private final String environment; + private final String environmentName; private final Context context; private final OkHttpClient client; private volatile boolean isOffline; - static HttpFeatureFlagFetcher newInstance(Context context, LDConfig config, String environment) { - return new HttpFeatureFlagFetcher(context, config, environment); + static HttpFeatureFlagFetcher newInstance(Context context, LDConfig config, String environmentName) { + return new HttpFeatureFlagFetcher(context, config, environmentName); } - private HttpFeatureFlagFetcher(Context context, LDConfig config, String environment) { + private HttpFeatureFlagFetcher(Context context, LDConfig config, String environmentName) { this.config = config; - this.environment = environment; + this.environmentName = environmentName; this.context = context; this.isOffline = config.isOffline(); @@ -75,7 +75,7 @@ private HttpFeatureFlagFetcher(Context context, LDConfig config, String environm public synchronized ListenableFuture fetch(LDUser user) { final SettableFuture doneFuture = SettableFuture.create(); - if (user != null && !isOffline && isInternetConnected(context)) { + if (user != null && !isOffline && isClientConnected(context, environmentName)) { final Request request = config.isUseReport() ? getReportRequest(user) @@ -136,7 +136,7 @@ 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(); Timber.d("Attempting to fetch Feature flags using uri: %s", uri); - final Request request = config.getRequestBuilderFor(environment) // default GET verb + final Request request = config.getRequestBuilderFor(environmentName) // default GET verb .url(uri) .build(); return request; @@ -147,7 +147,7 @@ private Request getReportRequest(LDUser user) { 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(environment) + final Request report = config.getRequestBuilderFor(environmentName) .method("REPORT", reportBody) // custom REPORT verb .url(reportUri) .build(); 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 294a305b..980f3088 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 @@ -47,7 +47,7 @@ import timber.log.Timber; -import static com.launchdarkly.android.Util.isInternetConnected; +import static com.launchdarkly.android.Util.isClientConnected; /** * Client for accessing LaunchDarkly's Feature Flag system. This class enforces a singleton pattern. @@ -64,6 +64,7 @@ public class LDClient implements LDClientInterface, Closeable { private static final long MAX_RETRY_TIME_MS = 3_600_000; // 1 hour private static final long RETRY_TIME_MS = 1_000; // 1 second + private final String environmentName; private final WeakReference application; private final LDConfig config; private final UserManager userManager; @@ -253,11 +254,12 @@ protected LDClient(final Application application, @NonNull final LDConfig config } @VisibleForTesting - protected LDClient(final Application application, @NonNull final LDConfig config, String environmentName) { + protected LDClient(final Application application, @NonNull final LDConfig config, final String environmentName) { Timber.i("Creating LaunchDarkly client. Version: %s", BuildConfig.VERSION_NAME); this.config = config; this.isOffline = config.isOffline(); this.application = new WeakReference<>(application); + this.environmentName = environmentName; SharedPreferences mobileKeySharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + config.getMobileKeys().get(environmentName) + "id", Context.MODE_PRIVATE); @@ -274,14 +276,14 @@ protected LDClient(final Application application, @NonNull final LDConfig config Timber.i("Using instance id: %s", instanceId); this.fetcher = HttpFeatureFlagFetcher.newInstance(application, config, environmentName); - this.userManager = UserManager.newInstance(application, fetcher, config.getMobileKeys().get(environmentName)); + this.userManager = UserManager.newInstance(application, fetcher, environmentName, config.getMobileKeys().get(environmentName)); Foreground foreground = Foreground.get(application); Foreground.Listener foregroundListener = new Foreground.Listener() { @Override public void onBecameForeground() { PollingUpdater.stop(application); isAppForegrounded = true; - if (isInternetConnected(application)) { + if (isClientConnected(application, environmentName)) { startForegroundUpdating(); } } @@ -318,8 +320,8 @@ public void run() { } private static void multiEnvironmentSharedPreferencesMigration(SharedPreferences instanceIdSharedPrefs, SharedPreferences newInstanceIdSharedPrefs, - SharedPreferences userSharedPrefs, SharedPreferences newUserSharedPrefs, - SharedPreferences userFlagSharedPrefs, SharedPreferences newUserFlagSharedPrefs) { + SharedPreferences userSharedPrefs, SharedPreferences newUserSharedPrefs, + SharedPreferences userFlagSharedPrefs, SharedPreferences newUserFlagSharedPrefs) { checkAndClearSharedPreferences(instanceIdSharedPrefs, newInstanceIdSharedPrefs); checkAndClearSharedPreferences(userFlagSharedPrefs, newUserFlagSharedPrefs); checkAndClearSharedPreferences(userSharedPrefs, newUserSharedPrefs); @@ -835,7 +837,7 @@ private void sendFlagRequestEvent(String flagKey, JsonElement value, JsonElement void startBackgroundPolling() { Application application = this.application.get(); - if (application != null && !config.isDisableBackgroundPolling() && !isOffline() && isInternetConnected(application)) { + if (application != null && !config.isDisableBackgroundPolling() && isClientConnected(application, environmentName)) { PollingUpdater.startBackgroundPolling(application); } } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java index 0d5e0332..9570c5fe 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdater.java @@ -14,6 +14,7 @@ import timber.log.Timber; +import static com.launchdarkly.android.Util.isClientConnected; import static com.launchdarkly.android.Util.isInternetConnected; public class PollingUpdater extends BroadcastReceiver { @@ -24,12 +25,15 @@ public class PollingUpdater extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { try { - LDClient client = LDClient.get(); - if (client != null && !client.isOffline() && isInternetConnected(context)) { + if (isInternetConnected(context)) { Timber.d("onReceive connected to the internet!"); Set environments = LDClient.getEnvironmentNames(); for (String environment : environments) { - UserManager userManager = LDClient.getForMobileKey(environment).get().getUserManager(); + if (!isClientConnected(context, environment)) { + Timber.d("Skipping offline environment: %s", environment); + continue; + } + UserManager userManager = LDClient.getForMobileKey(environment).getUserManager(); if (userManager == null) { Timber.e("UserManager singleton was accessed before it was initialized! doing nothing"); continue; 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 df29e3fd..e355c646 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 @@ -116,7 +116,7 @@ public void onError(Throwable t) { if (code == 401) { connection401Error = true; try { - LDClient clientSingleton = LDClient.get(); + LDClient clientSingleton = LDClient.getForMobileKey(environmentName); clientSingleton.setOffline(); } catch (LaunchDarklyException e) { Timber.e(e, "Client unavailable to be set offline"); 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 628ba432..91e807ca 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 @@ -53,22 +53,24 @@ class UserManager { private final UserLocalSharedPreferences userLocalSharedPreferences; private final FlagResponseSharedPreferences flagResponseSharedPreferences; private final SummaryEventSharedPreferences summaryEventSharedPreferences; + private final String environmentName; private LDUser currentUser; private final Util.LazySingleton jsonParser; private final ExecutorService executor; - static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher, String mobileKey) { - return new UserManager(application, fetcher, mobileKey); + static synchronized UserManager newInstance(Application application, FeatureFlagFetcher fetcher, String environmentName, String mobileKey) { + return new UserManager(application, fetcher, environmentName, mobileKey); } - UserManager(Application application, FeatureFlagFetcher fetcher, String mobileKey) { + 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.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "summaryevents"); + this.environmentName = environmentName; jsonParser = new Util.LazySingleton<>(new Util.Provider() { @Override @@ -121,7 +123,7 @@ public void onSuccess(JsonObject result) { @Override public void onFailure(@NonNull Throwable t) { - if (Util.isInternetConnected(application)) { + if (Util.isClientConnected(application, environmentName)) { Timber.e(t, "Error when attempting to set user: [%s] [%s]", currentUser.getAsUrlSafeBase64(), userBase64ToJson(currentUser.getAsUrlSafeBase64())); } syncCurrentUserToActiveUserAndLog(); 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 bd0c6f99..bb1a0e83 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,7 +9,7 @@ class Util { /** - * 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 to determine if the device is online. * * @param context * @return @@ -17,7 +17,17 @@ class Util { static boolean isInternetConnected(Context context) { ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); - boolean deviceConnected = activeNetwork != null && activeNetwork.isConnectedOrConnecting(); + return activeNetwork != null && activeNetwork.isConnectedOrConnecting(); + } + + /** + * Looks at both the Android device status and the {@link LDClient} to determine if any network calls should be made. + * + * @param context + * @return + */ + static boolean isClientConnected(Context context) { + boolean deviceConnected = isInternetConnected(context); try { return deviceConnected && !LDClient.get().isOffline(); } catch (LaunchDarklyException e) { @@ -26,6 +36,23 @@ 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. + * + * @param context + * @param environmentName + * @return + */ + 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"); + return false; + } + } + static class LazySingleton { private final Provider provider; private T instance; From 562fd56314ab50f1241d4ef8fbd617e24f36d209 Mon Sep 17 00:00:00 2001 From: torchhound Date: Tue, 22 Jan 2019 10:01:57 -0800 Subject: [PATCH 038/152] fix(UserManagerTest.java): incorrect number of arguments to UserManager instantiation in unit test --- .../java/com/launchdarkly/android/UserManagerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 97ebc4e3..9ef38427 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 @@ -51,7 +51,7 @@ public class UserManagerTest extends EasyMockSupport { @Before public void before() { - userManager = new UserManager(activityTestRule.getActivity().getApplication(), fetcher); + userManager = new UserManager(activityTestRule.getActivity().getApplication(), fetcher, "test", "test"); } @Test From 9edb0500ebc50c485630b20f4c30e87600396677 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 22 Jan 2019 10:40:02 -0800 Subject: [PATCH 039/152] Remove line of testing code accidentally left in and refactor shared preferences migration to make future migrations easier. --- .../com/launchdarkly/android/LDClient.java | 80 +++++++++---------- 1 file changed, 39 insertions(+), 41 deletions(-) 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 980f3088..67dcb10d 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 @@ -31,7 +31,6 @@ import java.io.IOException; import java.lang.ref.WeakReference; import java.security.NoSuchAlgorithmException; -import java.security.Security; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -56,7 +55,6 @@ public class LDClient implements LDClientInterface, Closeable { private static final String INSTANCE_ID_KEY = "instanceId"; - private static final String INSTANCE_CLEAR_KEY = "clear"; // Upon client init will get set to a Unique id per installation used when creating anonymous users private static String instanceId = "UNKNOWN_ANDROID"; private static Map instances = null; @@ -116,8 +114,6 @@ public static synchronized Future init(@NonNull Application applicatio Timber.plant(new Timber.DebugTree()); } - Security.removeProvider("AndroidOpenSSL"); - try { SSLContext.getInstance("TLSv1.2"); } catch (NoSuchAlgorithmException e) { @@ -139,16 +135,22 @@ public static synchronized Future init(@NonNull Application applicatio Map> updateFutures = new HashMap<>(); - for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { - String mobileKey = mobileKeys.getKey(); - multiEnvironmentSharedPreferencesMigration(application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "id", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "users", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "users", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "version", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "version", Context.MODE_PRIVATE)); + SharedPreferences instanceIdSharedPrefs = + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE); + + if (!instanceIdSharedPrefs.contains(INSTANCE_ID_KEY)) { + String uuid = UUID.randomUUID().toString(); + Timber.i("Did not find existing instance id. Saving a new one"); + SharedPreferences.Editor editor = instanceIdSharedPrefs.edit(); + editor.putString(INSTANCE_ID_KEY, uuid); + editor.apply(); } + instanceId = instanceIdSharedPrefs.getString(INSTANCE_ID_KEY, instanceId); + Timber.i("Using instance id: %s", instanceId); + + migrateWhenNeeded(application, config); + for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { final LDClient instance = new LDClient(application, config, mobileKeys.getKey()); instance.userManager.setCurrentUser(user); @@ -260,23 +262,9 @@ protected LDClient(final Application application, @NonNull final LDConfig config this.isOffline = config.isOffline(); this.application = new WeakReference<>(application); this.environmentName = environmentName; - - SharedPreferences mobileKeySharedPrefs = - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + config.getMobileKeys().get(environmentName) + "id", Context.MODE_PRIVATE); - - if (!mobileKeySharedPrefs.contains(INSTANCE_ID_KEY)) { - String uuid = UUID.randomUUID().toString(); - Timber.i("Did not find existing instance id. Saving a new one"); - SharedPreferences.Editor editor = mobileKeySharedPrefs.edit(); - editor.putString(INSTANCE_ID_KEY, uuid); - editor.apply(); - } - - instanceId = mobileKeySharedPrefs.getString(INSTANCE_ID_KEY, instanceId); - Timber.i("Using instance id: %s", instanceId); - this.fetcher = HttpFeatureFlagFetcher.newInstance(application, config, environmentName); this.userManager = UserManager.newInstance(application, fetcher, environmentName, config.getMobileKeys().get(environmentName)); + Foreground foreground = Foreground.get(application); Foreground.Listener foregroundListener = new Foreground.Listener() { @Override @@ -319,24 +307,33 @@ public void run() { } } - private static void multiEnvironmentSharedPreferencesMigration(SharedPreferences instanceIdSharedPrefs, SharedPreferences newInstanceIdSharedPrefs, - SharedPreferences userSharedPrefs, SharedPreferences newUserSharedPrefs, - SharedPreferences userFlagSharedPrefs, SharedPreferences newUserFlagSharedPrefs) { - checkAndClearSharedPreferences(instanceIdSharedPrefs, newInstanceIdSharedPrefs); - checkAndClearSharedPreferences(userFlagSharedPrefs, newUserFlagSharedPrefs); - checkAndClearSharedPreferences(userSharedPrefs, newUserSharedPrefs); - } + private static void migrateWhenNeeded(Application application, LDConfig config) { + SharedPreferences migrations = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "migrations", Context.MODE_PRIVATE); - private static void checkAndClearSharedPreferences(SharedPreferences oldPrefs, SharedPreferences newPrefs) { - if (!oldPrefs.contains(INSTANCE_CLEAR_KEY)) { - copySharedPreferences(oldPrefs, newPrefs); + if (!migrations.contains("v2.6.0")) { + Timber.d("Migrating to v2.6.0 multi-environment shared preferences"); + 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)); + allSuccess = allSuccess && users && version; + } - oldPrefs.edit().clear().commit(); - oldPrefs.edit().putString(INSTANCE_CLEAR_KEY, INSTANCE_CLEAR_KEY).apply(); + 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(); + } + } } } - private static void copySharedPreferences(SharedPreferences oldPreferences, SharedPreferences newPreferences) { + private static boolean copySharedPreferences(SharedPreferences oldPreferences, SharedPreferences newPreferences) { SharedPreferences.Editor editor = newPreferences.edit(); for (Map.Entry entry : oldPreferences.getAll().entrySet()) { @@ -353,8 +350,9 @@ else if (value instanceof Long) editor.putLong(key, (Long) value); else if (value instanceof String) editor.putString(key, ((String) value)); - editor.apply(); } + + return editor.commit(); } /** From 34c942a297a9ff8c3808546dc77e525b90d3e277 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 22 Jan 2019 14:01:49 -0800 Subject: [PATCH 040/152] Final fixes to store migration. Should be fairly future proof. --- .../com/launchdarkly/android/LDClient.java | 57 ++++++++++++++++++- .../android/UserLocalSharePreferences.java | 6 +- .../com/launchdarkly/android/UserManager.java | 4 +- 3 files changed, 59 insertions(+), 8 deletions(-) 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 67dcb10d..f2f6f7d0 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 @@ -28,11 +28,13 @@ import com.google.android.gms.security.ProviderInstaller; 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; @@ -312,14 +314,58 @@ private static void migrateWhenNeeded(Application application, LDConfig config) 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)); + 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)); - allSuccess = allSuccess && users && version; + 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) { @@ -328,6 +374,11 @@ private static void migrateWhenNeeded(Application application, LDConfig config) 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(); + } } } } 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 index 72f9bc13..8c756a27 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java @@ -46,7 +46,7 @@ class UserLocalSharedPreferences { UserLocalSharedPreferences(Application application, String mobileKey) { this.application = application; - this.usersSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "users", Context.MODE_PRIVATE); + this.usersSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-users", Context.MODE_PRIVATE); this.mobileKey = mobileKey; this.activeUserSharedPrefs = loadSharedPrefsForActiveUser(); HashMultimap> multimap = HashMultimap.create(); @@ -82,7 +82,7 @@ private SharedPreferences loadSharedPrefsForUser(String user) { } private String sharedPrefsKeyForUser(String user) { - return LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + user; + return LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + user + "-user"; } // Gets all users sorted by creation time (oldest first) @@ -134,7 +134,7 @@ private void deleteSharedPreferences(String userKey) { } private SharedPreferences loadSharedPrefsForActiveUser() { - String sharedPrefsKey = LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "active"; + 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); } 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 91e807ca..9b1f95ad 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 @@ -68,8 +68,8 @@ static synchronized UserManager newInstance(Application application, FeatureFlag 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.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "summaryevents"); + this.flagResponseSharedPreferences = new UserFlagResponseSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-version"); + this.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-summaryevents"); this.environmentName = environmentName; jsonParser = new Util.LazySingleton<>(new Util.Provider() { From 964c5cdb0322294379b2bd65d584f218a3754191 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 6 Feb 2019 01:47:41 +0000 Subject: [PATCH 041/152] Fix issue with primitive variation calls always returning null if fallback is null. --- .../com/launchdarkly/android/LDClient.java | 107 +++++++++--------- 1 file changed, 56 insertions(+), 51 deletions(-) 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..adf26c7a 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 @@ -511,25 +511,25 @@ public Void apply(List input) { * @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); + Boolean result = null; + if (flagKey != null) { + try { + result = (Boolean) userManager.getCurrentUserSharedPrefs().getAll().get(flagKey); + } catch (ClassCastException cce) { + Timber.e(cce, "Attempted to get boolean flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } + } else { + Timber.e("Attempted to get boolean flag with a default null value for key. Returning fallback: %s", fallback); } + result = result == null ? fallback : result; + int version = userManager.getFlagResponseSharedPreferences().getVersionForEvents(flagKey); int variation = userManager.getFlagResponseSharedPreferences().getStoredVariation(flagKey); - if (result == null && fallback == null) { + if (result == 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); @@ -555,22 +555,23 @@ public Boolean boolVariation(String flagKey, Boolean fallback) { */ @Override public Integer intVariation(String flagKey, Integer fallback) { + Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); 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); + if (flagKey == null) { + Timber.e("Attempted to get integer flag with a default null value for key. Returning fallback: %s", fallback); + } else if (sharedPrefs.containsKey(flagKey)) { + try { + result = (Integer) sharedPrefs.get(flagKey); + } catch (ClassCastException cce) { + Timber.e(cce, "Attempted to get integer 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); - if (result == null && fallback == null) { + if (result == 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); @@ -596,22 +597,23 @@ public Integer intVariation(String flagKey, Integer fallback) { */ @Override public Float floatVariation(String flagKey, Float fallback) { + Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); 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); + if (flagKey == null) { + Timber.e("Attempted to get float flag with a default null value for key. Returning fallback: %s", fallback); + } else if (sharedPrefs.containsKey(flagKey)) { + try { + result = (Float) sharedPrefs.get(flagKey); + } catch (ClassCastException cce) { + Timber.e(cce, "Attempted to get float 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); - if (result == null && fallback == null) { + if (result == 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); @@ -637,22 +639,23 @@ public Float floatVariation(String flagKey, Float fallback) { */ @Override public String stringVariation(String flagKey, String fallback) { + Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); 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); + if (flagKey == null) { + Timber.e("Attempted to get string flag with a default null value for key. Returning fallback: %s", fallback); + } else if (sharedPrefs.containsKey(flagKey)) { + try { + result = (String) sharedPrefs.get(flagKey); + } catch (ClassCastException cce) { + Timber.e(cce, "Attempted to get 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); - if (result == null && fallback == null) { + if (result == 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); @@ -678,19 +681,21 @@ public String stringVariation(String flagKey, String fallback) { */ @Override public JsonElement jsonVariation(String flagKey, JsonElement fallback) { + Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); JsonElement result = fallback; - try { - String stringResult = userManager.getCurrentUserSharedPrefs().getString(flagKey, null); - if (stringResult != null) { + if (flagKey == null) { + Timber.e("Attempted to get string flag with a default null value for key. Returning fallback: %s", fallback); + } else if (sharedPrefs.containsKey(flagKey)) { + try { + String stringResult = (String) sharedPrefs.get(flagKey); result = new JsonParser().parse(stringResult); + } 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 (JsonSyntaxException jse) { + Timber.e(jse, "Attempted to get json flag from string flag for key: %s Returning fallback: %s", flagKey, fallback); } - } 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); From 5fcd6b1a1ec834864df04644323cca552b201450 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 6 Feb 2019 01:51:38 +0000 Subject: [PATCH 042/152] Remove CircleCI V1 config file. (#97) --- circle.yml | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 circle.yml 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 From 1f5eb1f11196e6e75f0d8304d5ff38aa7f33198a Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Mon, 11 Feb 2019 22:36:48 +0000 Subject: [PATCH 043/152] Remove getting/comparing versions as floats (#99) To prevent floating point errors in flag version comparisons. --- .../android/response/UserFlagResponseSharedPreferences.java | 2 +- .../android/response/UserSummaryEventSharedPreferences.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 1b0aa9c1..1f333614 100644 --- 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 @@ -26,7 +26,7 @@ public UserFlagResponseSharedPreferences(Application application, String name) { @Override public boolean isVersionValid(FlagResponse flagResponse) { if (flagResponse != null && sharedPreferences.contains(flagResponse.getKey())) { - float storedVersion = getStoredVersion(flagResponse.getKey()); + int storedVersion = getStoredVersion(flagResponse.getKey()); return storedVersion < flagResponse.getVersion(); } return true; 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 index 55b28124..da6be61f 100644 --- 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 @@ -35,7 +35,7 @@ public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElem 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 isSameVersion = versionElement != null && asJsonObject.get("version").getAsInt() == version; boolean isSameVariation = variationElement != null && variationElement.getAsInt() == variation; if ((isSameVersion && isSameVariation) || (variationElement == null && versionElement == null && isUnknown)) { variationExists = true; From ae3791a9ba30c369465872ea3220af00c2cb0758 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Mon, 11 Feb 2019 22:51:33 +0000 Subject: [PATCH 044/152] Include values in unknown summary events and compare values for (#100) equality when creating new summary counters. --- .../android/response/UserSummaryEventSharedPreferences.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index da6be61f..144ca128 100644 --- 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 @@ -37,7 +37,7 @@ public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElem // We can compare variation rather than value. boolean isSameVersion = versionElement != null && asJsonObject.get("version").getAsInt() == version; boolean isSameVariation = variationElement != null && variationElement.getAsInt() == variation; - if ((isSameVersion && isSameVariation) || (variationElement == null && versionElement == null && isUnknown)) { + if ((isSameVersion && isSameVariation) || (variationElement == null && versionElement == null && isUnknown && value.equals(asJsonObject.get("value")))) { variationExists = true; int currentCount = asJsonObject.get("count").getAsInt(); asJsonObject.add("count", new JsonPrimitive(++currentCount)); @@ -73,6 +73,7 @@ private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElemen JsonObject newCounter = new JsonObject(); if (isUnknown) { newCounter.add("unknown", new JsonPrimitive(true)); + newCounter.add("value", value); } else { newCounter.add("value", value); newCounter.add("version", new JsonPrimitive(version)); From a6061cf0986bddaf0bcebf387819576cde7de1a1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Feb 2019 12:29:32 -0800 Subject: [PATCH 045/152] simplify flag property deserialization --- .../com/launchdarkly/android/EventTest.java | 2 +- .../launchdarkly/android/UserManagerTest.java | 44 ++++---- ...UserFlagResponseSharedPreferencesTest.java | 42 ++++---- .../java/com/launchdarkly/android/Event.java | 12 +-- .../com/launchdarkly/android/LDClient.java | 98 +++++++++-------- .../android/response/FlagResponse.java | 4 +- .../FlagResponseSharedPreferences.java | 12 +-- .../SummaryEventSharedPreferences.java | 4 +- .../android/response/UserFlagResponse.java | 24 +++-- .../UserFlagResponseSharedPreferences.java | 102 +++--------------- .../UserSummaryEventSharedPreferences.java | 3 +- .../BaseFlagResponseInterpreter.java | 41 ------- .../DeleteFlagResponseInterpreter.java | 12 +-- .../PatchFlagResponseInterpreter.java | 17 +-- .../PingFlagResponseInterpreter.java | 21 ++-- .../PutFlagResponseInterpreter.java | 18 +--- .../interpreter/UserFlagResponseParser.java | 55 ++++++++++ 17 files changed, 203 insertions(+), 308 deletions(-) delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/BaseFlagResponseInterpreter.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java 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..df677d6f 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 @@ -244,7 +244,7 @@ public void testOptionalFieldsAreExcludedAppropriately() { LDUser user = builder.build(); - final FeatureRequestEvent hasVersionEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, 5, -1); + final FeatureRequestEvent hasVersionEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, 5, null); final FeatureRequestEvent hasVariationEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, -1, 20); Assert.assertEquals(5, hasVersionEvent.version, 0.0f); 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..db0285a0 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 @@ -268,48 +268,48 @@ public void TestPatchSucceedsForMissingVersionInPatch() throws ExecutionExceptio //// 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")); + assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("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")); + assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("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")); + assertEquals(558, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("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")); + assertEquals(558, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("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")); + assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersion()); + assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); + assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("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")); + assertEquals(559, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); } @Test @@ -334,20 +334,20 @@ public void TestPatchWithVersion() throws ExecutionException, InterruptedExcepti SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); FlagResponseSharedPreferences flagResponseSharedPreferences = userManager.getFlagResponseSharedPreferences(); assertEquals("string1", sharedPrefs.getString("stringFlag1", "")); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagVersion("stringFlag1")); - assertEquals(125, flagResponseSharedPreferences.getVersionForEvents("stringFlag1")); + assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getFlagVersion()); + assertEquals(125, flagResponseSharedPreferences.getStoredFlagResponse("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")); + assertEquals(126, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersion()); + assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getFlagVersion()); + assertEquals(126, flagResponseSharedPreferences.getStoredFlagResponse("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")); + assertEquals(127, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getFlagVersion()); + assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersionForEvents()); userManager.patchCurrentUserFlags("{\"key\":\"stringFlag20\",\"version\":1,\"value\":\"stringValue\"}").get(); assertEquals("stringValue", sharedPrefs.getString("stringFlag20", "")); 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 index ed0a7acc..0bde577b 100644 --- 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 @@ -30,8 +30,8 @@ public void savesVersions() { = 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); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getVersion(), 12, 0); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getVersion(), -1, 0); } @Test @@ -43,7 +43,7 @@ public void deletesVersions() { versionSharedPreferences.saveAll(Collections.singletonList(key1)); versionSharedPreferences.deleteStoredFlagResponse(key1); - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key1.getKey()), -1, 0); + Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); } @Test @@ -57,7 +57,7 @@ public void updatesVersions() { versionSharedPreferences.updateStoredFlagResponse(updatedKey1); - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key1.getKey()), 15, 0); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getVersion(), 15, 0); } @Test @@ -70,8 +70,8 @@ public void clearsFlags() { 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.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); + Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key2.getKey())); Assert.assertEquals(0, versionSharedPreferences.getLength(), 0); } @@ -85,9 +85,9 @@ public void savesVariation() { = 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); + Assert.assertEquals(16, versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getVariation(), 0); + Assert.assertEquals(23, versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getVariation(),0); + Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).getVariation()); } @Test @@ -100,9 +100,9 @@ public void savesTrackEvents() { = 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())); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).isTrackEvents(), false); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).isTrackEvents(), true); + Assert.assertFalse(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).isTrackEvents()); } @Test @@ -116,10 +116,10 @@ public void savesDebugEventsUntilDate() { versionSharedPreferences.saveAll(Arrays.asList(key1, key2, key3)); //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredDebugEventsUntilDate(key1.getKey()), 123456789L, 0); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getDebugEventsUntilDate(), 123456789L, 0); //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredDebugEventsUntilDate(key2.getKey()), 987654321L, 0); - Assert.assertNull(versionSharedPreferences.getStoredDebugEventsUntilDate(key3.getKey())); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getDebugEventsUntilDate(), 987654321L, 0); + Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).getDebugEventsUntilDate()); } @@ -132,8 +132,8 @@ public void savesFlagVersions() { = 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); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getFlagVersion(), 12, 0); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getFlagVersion(), -1, 0); } @Test @@ -145,7 +145,7 @@ public void deletesFlagVersions() { versionSharedPreferences.saveAll(Collections.singletonList(key1)); versionSharedPreferences.deleteStoredFlagResponse(key1); - Assert.assertEquals(versionSharedPreferences.getStoredFlagVersion(key1.getKey()), -1, 0); + Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); } @Test @@ -159,7 +159,7 @@ public void updatesFlagVersions() { versionSharedPreferences.updateStoredFlagResponse(updatedKey1); - Assert.assertEquals(versionSharedPreferences.getStoredFlagVersion(key1.getKey()), 15, 0); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getFlagVersion(), 15, 0); } @Test @@ -171,8 +171,8 @@ public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() = 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); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(withFlagVersion.getKey()).getVersionForEvents(), 13, 0); + Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(withOnlyVersion.getKey()).getVersionForEvents(), 12, 0); } } 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..bc9f2682 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; @@ -87,7 +87,7 @@ 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) { super("feature", key, user); this.value = value; this.defaultVal = defaultVal; @@ -107,7 +107,7 @@ 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) { super("feature", key, null); this.value = value; this.defaultVal = defaultVal; @@ -115,14 +115,14 @@ class FeatureRequestEvent extends GenericEvent { setOptionalValues(version, variation); } - private void setOptionalValues(int version, int variation) { + private void setOptionalValues(int version, @Nullable Integer variation) { 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); @@ -132,7 +132,7 @@ private void setOptionalValues(int version, int variation) { 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) { + DebugEvent(String key, LDUser user, JsonElement value, JsonElement defaultVal, @IntRange(from=(0), to=(Integer.MAX_VALUE)) int version, @Nullable Integer variation) { super(key, user, value, defaultVal, version, variation); this.kind = "debug"; } 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..44b6e2fe 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 @@ -24,6 +24,7 @@ import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import com.google.gson.JsonSyntaxException; +import com.launchdarkly.android.response.FlagResponse; import com.launchdarkly.android.response.SummaryEventSharedPreferences; import com.google.android.gms.security.ProviderInstaller; @@ -522,20 +523,19 @@ public Boolean boolVariation(String flagKey, Boolean 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); + FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, null, null); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, null, new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, new JsonPrimitive(fallback)); } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); } Timber.d("boolVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; @@ -563,20 +563,19 @@ public Integer intVariation(String flagKey, Integer 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); + FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, null, null); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, null, new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, new JsonPrimitive(fallback)); } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); } Timber.d("intVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; @@ -604,20 +603,19 @@ public Float floatVariation(String flagKey, Float 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); + FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, null, null); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, null, new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, new JsonPrimitive(fallback)); } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); } Timber.d("floatVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; @@ -645,20 +643,19 @@ public String stringVariation(String flagKey, String 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); + FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, null, null); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, null, new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, new JsonPrimitive(fallback)); } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); + updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); } Timber.d("stringVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; @@ -691,10 +688,9 @@ public JsonElement jsonVariation(String flagKey, JsonElement 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); + FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); + updateSummaryEvents(flagKey, flag, result, fallback); + sendFlagRequestEvent(flagKey, flag, result, fallback); Timber.d("jsonVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -864,15 +860,17 @@ 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, FlagResponse flag, JsonElement value, JsonElement fallback) { + int version = flag == null ? -1 : flag.getVersionForEvents(); + Integer variation = flag == null ? null : flag.getVariation(); + if (flag != null && flag.isTrackEvents()) { if (config.inlineUsersInEvents()) { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser(), value, fallback, version, variation)); } else { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser().getKeyAsString(), value, fallback, version, variation)); } } else { - Long debugEventsUntilDate = userManager.getFlagResponseSharedPreferences().getStoredDebugEventsUntilDate(flagKey); + Long debugEventsUntilDate = flag == null ? null : flag.getDebugEventsUntilDate(); if (debugEventsUntilDate != null) { long serverTimeMs = eventProcessor.getCurrentTimeMs(); if (debugEventsUntilDate > System.currentTimeMillis() && debugEventsUntilDate > serverTimeMs) { @@ -908,9 +906,9 @@ private void sendEvent(Event event) { * @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); + private void updateSummaryEvents(String flagKey, FlagResponse flag, JsonElement result, JsonElement fallback) { + int version = flag == null ? -1 : flag.getVersionForEvents(); + Integer variation = flag == null ? null : flag.getVariation(); boolean isUnknown = !userManager.getFlagResponseSharedPreferences().containsKey(flagKey); userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, version, variation, isUnknown); 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 index a7f69d7f..9c247e6f 100644 --- 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 @@ -17,9 +17,11 @@ public interface FlagResponse { int getFlagVersion(); + int getVersionForEvents(); + Integer getVariation(); - Boolean getTrackEvents(); + boolean isTrackEvents(); Long getDebugEventsUntilDate(); 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 index 18838643..edc0ef58 100644 --- 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 @@ -18,17 +18,7 @@ public interface FlagResponseSharedPreferences { void updateStoredFlagResponse(FlagResponse flagResponse); - int getStoredVersion(String flagResponseKey); - - int getStoredFlagVersion(String flagResponseKey); - - Long getStoredDebugEventsUntilDate(String flagResponseKey); - - boolean getStoredTrackEvents(String flagResponseKey); - - int getStoredVariation(String flagResponseKey); + FlagResponse getStoredFlagResponse(String flagResponseKey); boolean containsKey(String key); - - int getVersionForEvents(String flagResponseKey); } 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 index 3d1e69c5..b5b710fd 100644 --- 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 @@ -1,5 +1,7 @@ package com.launchdarkly.android.response; +import android.support.annotation.Nullable; + import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -11,7 +13,7 @@ public interface SummaryEventSharedPreferences { void clear(); - void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, int variation, boolean unknown); + void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer 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 index ece3a93d..8e6e19bd 100644 --- 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 @@ -27,7 +27,7 @@ public class UserFlagResponse implements FlagResponse { private final Integer variation; @Nullable - private final Boolean trackEvents; + private final boolean trackEvents; @Nullable private final Long debugEventsUntilDate; @@ -38,7 +38,7 @@ public UserFlagResponse(@NonNull String key, @Nullable JsonElement value, int ve this.version = version; this.flagVersion = flagVersion; this.variation = variation; - this.trackEvents = trackEvents; + this.trackEvents = trackEvents == null ? false : trackEvents.booleanValue(); this.debugEventsUntilDate = debugEventsUntilDate; } @@ -72,15 +72,19 @@ public int getFlagVersion() { return flagVersion; } + @Override + public int getVersionForEvents() { + return flagVersion > 0 ? flagVersion : version; + } + @Nullable @Override public Integer getVariation() { return variation; } - @Nullable @Override - public Boolean getTrackEvents() { + public boolean isTrackEvents() { return trackEvents; } @@ -95,9 +99,15 @@ 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)); + if (variation != null) { + object.add("variation", new JsonPrimitive(variation)); + } + if (trackEvents) { + object.add("trackEvents", new JsonPrimitive(true)); + } + if (debugEventsUntilDate != null) { + object.add("debugEventsUntilDate", new JsonPrimitive(debugEventsUntilDate)); + } return object; } 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 index 1b0aa9c1..f5268627 100644 --- 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 @@ -8,6 +8,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.launchdarkly.android.response.interpreter.UserFlagResponseParser; import java.util.List; @@ -25,9 +27,11 @@ public UserFlagResponseSharedPreferences(Application application, String name) { @Override public boolean isVersionValid(FlagResponse flagResponse) { - if (flagResponse != null && sharedPreferences.contains(flagResponse.getKey())) { - float storedVersion = getStoredVersion(flagResponse.getKey()); - return storedVersion < flagResponse.getVersion(); + if (flagResponse != null) { + FlagResponse storedFlag = getStoredFlagResponse(flagResponse.getKey()); + if (storedFlag != null) { + return storedFlag.getVersion() < flagResponse.getVersion(); + } } return true; } @@ -37,6 +41,8 @@ public void saveAll(List flagResponseList) { SharedPreferences.Editor editor = sharedPreferences.edit(); for (FlagResponse flagResponse : flagResponseList) { + String s = flagResponse.getAsJsonObject().toString(); + android.util.Log.d("ELI", "*** " + flagResponse.getKey() + ": " + s); editor.putString(flagResponse.getKey(), flagResponse.getAsJsonObject().toString()); } editor.apply(); @@ -57,85 +63,9 @@ public void updateStoredFlagResponse(FlagResponse flagResponse) { } @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; + public FlagResponse getStoredFlagResponse(String key) { + JsonObject jsonObject = getValueAsJsonObject(key); + return jsonObject == null ? null : UserFlagResponseParser.parseFlag(jsonObject, key); } @Override @@ -147,12 +77,4 @@ public boolean containsKey(String key) { 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/UserSummaryEventSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java index 55b28124..0349c4ba 100644 --- 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 @@ -21,7 +21,8 @@ public UserSummaryEventSharedPreferences(Application application, String name) { } @Override - public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, int variation, boolean isUnknown) { + public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer nullableVariation, boolean isUnknown) { + int variation = nullableVariation == null ? -1 : nullableVariation; JsonObject object = getValueAsJsonObject(flagResponseKey); if (object == null) { object = createNewEvent(value, defaultVal, version, variation, isUnknown); 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 index 16dcfe80..3a7b4c0f 100644 --- 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 @@ -11,29 +11,21 @@ * Farhan * 2018-01-30 */ -public class DeleteFlagResponseInterpreter extends BaseFlagResponseInterpreter { +public class DeleteFlagResponseInterpreter implements FlagResponseInterpreter { @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 new UserFlagResponse(key, null, version, -1, -1, false, null); } } return null; 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 index f7a3287c..ad6cf6ef 100644 --- 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 @@ -3,7 +3,6 @@ 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; @@ -11,29 +10,17 @@ * Farhan * 2018-01-30 */ -public class PatchFlagResponseInterpreter extends BaseFlagResponseInterpreter { +public class PatchFlagResponseInterpreter implements FlagResponseInterpreter { @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 UserFlagResponseParser.parseFlag(input, key); } } 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 index 25506246..95430ec6 100644 --- 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 @@ -17,7 +17,7 @@ * Farhan * 2018-01-30 */ -public class PingFlagResponseInterpreter extends BaseFlagResponseInterpreter> { +public class PingFlagResponseInterpreter implements FlagResponseInterpreter> { @NonNull @Override @@ -31,20 +31,7 @@ public List apply(@Nullable JsonObject input) { 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)); + flagResponseList.add(UserFlagResponseParser.parseFlag(asJsonObject, key)); } else { flagResponseList.add(new UserFlagResponse(key, v)); } @@ -52,4 +39,8 @@ public List apply(@Nullable JsonObject input) { } return flagResponseList; } + + protected boolean isValueInsideObject(JsonElement element) { + return !element.isJsonNull() && element.isJsonObject() && element.getAsJsonObject().has("value"); + } } 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 index acca8fac..598a971c 100644 --- 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 @@ -5,7 +5,6 @@ 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; @@ -17,7 +16,7 @@ * Farhan * 2018-01-30 */ -public class PutFlagResponseInterpreter extends BaseFlagResponseInterpreter> { +public class PutFlagResponseInterpreter implements FlagResponseInterpreter> { @NonNull @Override @@ -30,20 +29,7 @@ public List apply(@Nullable JsonObject input) { 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)); + flagResponseList.add(UserFlagResponseParser.parseFlag(asJsonObject, key)); } } } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java new file mode 100644 index 00000000..ee59161a --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java @@ -0,0 +1,55 @@ +package com.launchdarkly.android.response.interpreter; + +import android.support.annotation.Nullable; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.launchdarkly.android.response.UserFlagResponse; + +public class UserFlagResponseParser { + + @Nullable + public static Long getDebugEventsUntilDate(JsonObject object) { + if (object == null || object.get("debugEventsUntilDate") == null || object.get("debugEventsUntilDate").isJsonNull()) { + return null; + } + return object.get("debugEventsUntilDate").getAsLong(); + } + + @Nullable + public static Boolean getTrackEvents(JsonObject object) { + if (object == null || object.get("trackEvents") == null || object.get("trackEvents").isJsonNull()) { + return null; + } + return object.get("trackEvents").getAsBoolean(); + } + + @Nullable + public static Integer getVariation(JsonObject object) { + if (object == null || object.get("variation") == null || object.get("variation").isJsonNull()) { + return null; + } + return object.get("variation").getAsInt(); + } + + public static UserFlagResponse parseFlag(JsonObject o, String key) { + android.util.Log.d("ELI", "*** parseFlag: " + key + ": " + o.toString()); + if (o == null) { + return null; + } + JsonElement versionElement = o.get("version"); + JsonElement valueElement = o.get("value"); + JsonElement flagVersionElement = o.get("flagVersion"); + Boolean trackEvents = getTrackEvents(o); + Long debugEventsUntilDate = getDebugEventsUntilDate(o); + int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() + ? versionElement.getAsInt() + : -1; + Integer variation = getVariation(o); + int flagVersion = flagVersionElement != null && flagVersionElement.getAsJsonPrimitive().isNumber() + ? flagVersionElement.getAsInt() + : -1; + JsonElement reasonElement = o.get("reason"); + return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate); + } +} From 21677bedba65b220435d856ba1208d70429133cd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Feb 2019 12:35:50 -0800 Subject: [PATCH 046/152] rm debugging --- .../android/response/UserFlagResponseSharedPreferences.java | 1 - .../android/response/interpreter/UserFlagResponseParser.java | 1 - 2 files changed, 2 deletions(-) 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 index f5268627..01ab929c 100644 --- 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 @@ -42,7 +42,6 @@ public void saveAll(List flagResponseList) { for (FlagResponse flagResponse : flagResponseList) { String s = flagResponse.getAsJsonObject().toString(); - android.util.Log.d("ELI", "*** " + flagResponse.getKey() + ": " + s); editor.putString(flagResponse.getKey(), flagResponse.getAsJsonObject().toString()); } editor.apply(); diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java index ee59161a..e9355012 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java @@ -33,7 +33,6 @@ public static Integer getVariation(JsonObject object) { } public static UserFlagResponse parseFlag(JsonObject o, String key) { - android.util.Log.d("ELI", "*** parseFlag: " + key + ": " + o.toString()); if (o == null) { return null; } From 8d23b67d45ff793d6cd40c629eb670a685d7c273 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Feb 2019 12:45:52 -0800 Subject: [PATCH 047/152] misc cleanup --- .../interpreter/UserFlagResponseParser.java | 56 ++++++++----------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java index e9355012..8729483d 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java @@ -4,51 +4,41 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import com.launchdarkly.android.response.UserFlagResponse; public class UserFlagResponseParser { - @Nullable - public static Long getDebugEventsUntilDate(JsonObject object) { - if (object == null || object.get("debugEventsUntilDate") == null || object.get("debugEventsUntilDate").isJsonNull()) { - return null; - } - return object.get("debugEventsUntilDate").getAsLong(); - } - - @Nullable - public static Boolean getTrackEvents(JsonObject object) { - if (object == null || object.get("trackEvents") == null || object.get("trackEvents").isJsonNull()) { - return null; - } - return object.get("trackEvents").getAsBoolean(); - } - - @Nullable - public static Integer getVariation(JsonObject object) { - if (object == null || object.get("variation") == null || object.get("variation").isJsonNull()) { - return null; - } - return object.get("variation").getAsInt(); - } - public static UserFlagResponse parseFlag(JsonObject o, String key) { if (o == null) { return null; } - JsonElement versionElement = o.get("version"); - JsonElement valueElement = o.get("value"); - JsonElement flagVersionElement = o.get("flagVersion"); - Boolean trackEvents = getTrackEvents(o); - Long debugEventsUntilDate = getDebugEventsUntilDate(o); - int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() + JsonPrimitive versionElement = getPrimitive(o, "version"); + JsonPrimitive valueElement = getPrimitive(o, "value"); + JsonPrimitive flagVersionElement = getPrimitive(o, "flagVersion"); + JsonPrimitive variationElement = getPrimitive(o, "variation"); + JsonPrimitive trackEventsElement = getPrimitive(o, "trackEvents"); + JsonPrimitive debugEventsUntilDateElement = getPrimitive(o, "debugEventsUntilDate"); + int version = versionElement != null && versionElement.isNumber() ? versionElement.getAsInt() : -1; - Integer variation = getVariation(o); - int flagVersion = flagVersionElement != null && flagVersionElement.getAsJsonPrimitive().isNumber() + Integer variation = variationElement != null && variationElement.isNumber() + ? variationElement.getAsInt() + : null; + int flagVersion = flagVersionElement != null && flagVersionElement.isNumber() ? flagVersionElement.getAsInt() : -1; - JsonElement reasonElement = o.get("reason"); + boolean trackEvents = trackEventsElement != null && trackEventsElement.isBoolean() + && trackEventsElement.getAsBoolean(); + Long debugEventsUntilDate = debugEventsUntilDateElement != null && debugEventsUntilDateElement.isNumber() + ? debugEventsUntilDateElement.getAsLong() + : null; return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate); } + + @Nullable + private static JsonPrimitive getPrimitive(JsonObject o, String name) { + JsonElement e = o.get(name); + return e != null && e.isJsonPrimitive() ? e.getAsJsonPrimitive() : null; + } } From 86fb7f9eb7ff1ed74725808960dac5dfe809be35 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Feb 2019 12:49:15 -0800 Subject: [PATCH 048/152] rm debugging --- .../android/response/UserFlagResponseSharedPreferences.java | 1 - 1 file changed, 1 deletion(-) 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 index 01ab929c..cf06f08d 100644 --- 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 @@ -41,7 +41,6 @@ public void saveAll(List flagResponseList) { SharedPreferences.Editor editor = sharedPreferences.edit(); for (FlagResponse flagResponse : flagResponseList) { - String s = flagResponse.getAsJsonObject().toString(); editor.putString(flagResponse.getKey(), flagResponse.getAsJsonObject().toString()); } editor.apply(); From dc2d450522e8c7ada44809e4ebc555ac816b8082 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Feb 2019 13:25:11 -0800 Subject: [PATCH 049/152] add eval reason data model classes --- .../android/EvaluationReasonTest.java | 66 ++++ .../android/EvaluationDetail.java | 85 +++++ .../android/EvaluationReason.java | 329 ++++++++++++++++++ .../android/response/FlagResponse.java | 3 + .../android/response/UserFlagResponse.java | 17 +- .../interpreter/UserFlagResponseParser.java | 61 +++- 6 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationDetail.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java 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..2820db6f --- /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 Gson(); + + @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/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..d619a0dd --- /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, LDUser, 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..1b82cef2 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java @@ -0,0 +1,329 @@ +package com.launchdarkly.android; + +import android.support.annotation.Nullable; + +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 {@code boolVariationDetail()}. + * + * 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 + } + + 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 { + private final int ruleIndex; + 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 { + 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 { + 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/response/FlagResponse.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java index 9c247e6f..df4e8392 100644 --- 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 @@ -1,5 +1,6 @@ package com.launchdarkly.android.response; +import com.launchdarkly.android.EvaluationReason; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -25,6 +26,8 @@ public interface FlagResponse { Long getDebugEventsUntilDate(); + EvaluationReason getReason(); + JsonObject getAsJsonObject(); boolean isVersionMissing(); 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 index 8e6e19bd..a771d923 100644 --- 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 @@ -7,6 +7,7 @@ import com.google.gson.JsonNull; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; /** * Farhan @@ -32,7 +33,10 @@ public class UserFlagResponse implements FlagResponse { @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) { + @Nullable + private final EvaluationReason reason; + + public UserFlagResponse(@NonNull String key, @Nullable JsonElement value, int version, int flagVersion, @Nullable Integer variation, @Nullable Boolean trackEvents, @Nullable Long debugEventsUntilDate, @Nullable EvaluationReason reason) { this.key = key; this.value = value; this.version = version; @@ -40,14 +44,15 @@ public UserFlagResponse(@NonNull String key, @Nullable JsonElement value, int ve this.variation = variation; this.trackEvents = trackEvents == null ? false : trackEvents.booleanValue(); this.debugEventsUntilDate = debugEventsUntilDate; + this.reason = reason; } public UserFlagResponse(String key, JsonElement value) { - this(key, value, -1, -1, null, null, null); + this(key, value, -1, -1, null, null, null, null); } public UserFlagResponse(String key, JsonElement value, int version, int flagVersion) { - this(key, value, version, flagVersion, null, null, null); + this(key, value, version, flagVersion, null, null, null, null); } @NonNull @@ -94,6 +99,12 @@ public Long getDebugEventsUntilDate() { return debugEventsUntilDate; } + @Nullable + @Override + public EvaluationReason getReason() { + return reason; + } + @Override public JsonObject getAsJsonObject() { JsonObject object = new JsonObject(); diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java index 8729483d..69b47736 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java @@ -5,6 +5,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; import com.launchdarkly.android.response.UserFlagResponse; public class UserFlagResponseParser { @@ -19,6 +20,7 @@ public static UserFlagResponse parseFlag(JsonObject o, String key) { JsonPrimitive variationElement = getPrimitive(o, "variation"); JsonPrimitive trackEventsElement = getPrimitive(o, "trackEvents"); JsonPrimitive debugEventsUntilDateElement = getPrimitive(o, "debugEventsUntilDate"); + JsonElement reasonElement = o.get("reason"); int version = versionElement != null && versionElement.isNumber() ? versionElement.getAsInt() : -1; @@ -33,7 +35,10 @@ public static UserFlagResponse parseFlag(JsonObject o, String key) { Long debugEventsUntilDate = debugEventsUntilDateElement != null && debugEventsUntilDateElement.isNumber() ? debugEventsUntilDateElement.getAsLong() : null; - return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate); + EvaluationReason reason = reasonElement != null && reasonElement.isJsonObject() + ? parseReason(reasonElement.getAsJsonObject()) + : null; + return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate, reason); } @Nullable @@ -41,4 +46,58 @@ private static JsonPrimitive getPrimitive(JsonObject o, String name) { JsonElement e = o.get(name); return e != null && e.isJsonPrimitive() ? e.getAsJsonPrimitive() : null; } + + @Nullable + private static EvaluationReason parseReason(JsonObject o) { + 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; + } + } + return null; + } + + @Nullable + private static T parseEnum(Class c, String name, T fallback) { + try { + return Enum.valueOf(c, name); + } catch (IllegalArgumentException e) { + return fallback; + } + } } From 18a488dcdf3898fa254d8a69ab3d56f00c80ee54 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Feb 2019 14:25:29 -0800 Subject: [PATCH 050/152] misc fixes --- .../UserFlagResponseSharedPreferencesTest.java | 12 ++++++------ .../main/java/com/launchdarkly/android/LDClient.java | 4 ++-- .../interpreter/DeleteFlagResponseInterpreter.java | 2 +- .../response/interpreter/UserFlagResponseParser.java | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) 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 index 0bde577b..59300f1e 100644 --- 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 @@ -77,8 +77,8 @@ public void clearsFlags() { @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 key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, null, null, null); + final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, null, null, null); final UserFlagResponse key3 = new UserFlagResponse("key3", new JsonPrimitive(true), 16, -1); FlagResponseSharedPreferences versionSharedPreferences @@ -92,8 +92,8 @@ public void savesVariation() { @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 key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L, null); + final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L, null); final UserFlagResponse key3 = new UserFlagResponse("key3", new JsonPrimitive(true), 16, -1); FlagResponseSharedPreferences versionSharedPreferences @@ -107,8 +107,8 @@ public void savesTrackEvents() { @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 key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L, null); + final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L, null); final UserFlagResponse key3 = new UserFlagResponse("key3", new JsonPrimitive(true), 16, -1); FlagResponseSharedPreferences versionSharedPreferences 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 6a8f69ef..a497d84a 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 @@ -528,8 +528,8 @@ public Boolean boolVariation(String flagKey, Boolean fallback) { FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); if (result == null) { - updateSummaryEvents(flagKey, flag, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, new JsonPrimitive(fallback)); + updateSummaryEvents(flagKey, flag, null, null); + sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, null); } else if (fallback == null) { updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); 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 index 3a7b4c0f..90f46003 100644 --- 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 @@ -25,7 +25,7 @@ public FlagResponse apply(@Nullable JsonObject input) { if (keyElement != null) { String key = keyElement.getAsJsonPrimitive().getAsString(); - return new UserFlagResponse(key, null, version, -1, -1, false, null); + return new UserFlagResponse(key, null, version, -1, -1, false, null, null); } } return null; diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java index 69b47736..0c7d2de4 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java @@ -93,7 +93,7 @@ private static EvaluationReason parseReason(JsonObject o) { } @Nullable - private static T parseEnum(Class c, String name, T fallback) { + private static > T parseEnum(Class c, String name, T fallback) { try { return Enum.valueOf(c, name); } catch (IllegalArgumentException e) { From eb5a98fab1a91203dfd32f6414b0abe18f11d5b3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Feb 2019 14:38:27 -0800 Subject: [PATCH 051/152] serialize reason --- .../com/launchdarkly/android/response/UserFlagResponse.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index a771d923..04f7eb62 100644 --- 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 @@ -3,6 +3,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; @@ -14,6 +15,7 @@ * 2018-01-30 */ public class UserFlagResponse implements FlagResponse { + private static Gson gson = new Gson(); @NonNull private final String key; @@ -119,6 +121,9 @@ public JsonObject getAsJsonObject() { if (debugEventsUntilDate != null) { object.add("debugEventsUntilDate", new JsonPrimitive(debugEventsUntilDate)); } + if (reason != null) { + object.add("reason", gson.toJsonTree(reason)); + } return object; } From 352eb42e8c9d84e9f6cb4d4ba3758d43f5932476 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 13 Feb 2019 12:09:44 -0800 Subject: [PATCH 052/152] add ability to receive evaluation reasons from LD --- .../launchdarkly/android/LDConfigTest.java | 7 + ...UserFlagResponseSharedPreferencesTest.java | 19 ++ .../response/UserFlagResponseTest.java | 194 ++++++++++++++++++ .../android/HttpFeatureFlagFetcher.java | 6 + .../com/launchdarkly/android/LDConfig.java | 30 ++- .../android/StreamUpdateProcessor.java | 4 + .../android/response/UserFlagResponse.java | 1 + .../interpreter/UserFlagResponseParser.java | 2 +- 8 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java 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..64b02fe1 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 @@ -34,6 +34,7 @@ public void TestBuilderDefaults() { assertEquals(null, config.getMobileKey()); assertFalse(config.inlineUsersInEvents()); + assertFalse(config.isEvaluationReasons()); } @@ -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/response/UserFlagResponseSharedPreferencesTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java index 59300f1e..994e17ea 100644 --- 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 @@ -4,6 +4,7 @@ import android.support.test.runner.AndroidJUnit4; import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; import com.launchdarkly.android.test.TestActivity; import org.junit.Assert; @@ -175,4 +176,22 @@ public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(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 + // UserFlagResponseTest. + final EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); + final UserFlagResponse flag1 = new UserFlagResponse("key1", new JsonPrimitive(true), 11, + 1, 1, null, null, reason); + final UserFlagResponse flag2 = new UserFlagResponse("key2", new JsonPrimitive(true), 11, + 1, 1, null, null, null); + + FlagResponseSharedPreferences versionSharedPreferences + = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); + versionSharedPreferences.saveAll(Arrays.asList(flag1, flag2)); + + Assert.assertEquals(reason, versionSharedPreferences.getStoredFlagResponse(flag1.getKey()).getReason()); + Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(flag2.getKey()).getReason()); + } } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java new file mode 100644 index 00000000..69ab653f --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java @@ -0,0 +1,194 @@ +package com.launchdarkly.android.response; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.response.interpreter.UserFlagResponseParser; + +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class UserFlagResponseTest { + private static final Gson gson = new Gson(); + + 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 valueIsSerialized() { + final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes")); + final JsonObject json = r.getAsJsonObject(); + assertEquals(new JsonPrimitive("yes"), json.get("value")); + } + + @Test + public void valueIsDeserialized() { + final String jsonStr = "{\"value\": \"yes\"}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertEquals(new JsonPrimitive("yes"), r.getValue()); + } + + @Test + public void versionIsSerialized() { + final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + final JsonObject json = r.getAsJsonObject(); + assertEquals(new JsonPrimitive(99), json.get("version")); + } + + @Test + public void versionIsDeserialized() { + final String jsonStr = "{\"version\": 99}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertEquals(99, r.getVersion()); + } + + @Test + public void flagVersionIsSerialized() { + final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + final JsonObject json = r.getAsJsonObject(); + assertEquals(new JsonPrimitive(100), json.get("flagVersion")); + } + + @Test + public void flagVersionIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"flagVersion\": 100}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertEquals(100, r.getFlagVersion()); + } + + @Test + public void flagVersionDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertEquals(-1, r.getFlagVersion()); + } + + @Test + public void variationIsSerialized() { + final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, null, null, null); + final JsonObject json = r.getAsJsonObject(); + assertEquals(new JsonPrimitive(2), json.get("variation")); + } + + @Test + public void variationIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"variation\": 2}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertEquals(new Integer(2), r.getVariation()); + } + + @Test + public void variationDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertNull(r.getVariation()); + } + + @Test + public void trackEventsIsSerialized() { + final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, true, null, null); + final JsonObject json = r.getAsJsonObject(); + assertEquals(new JsonPrimitive(true), json.get("trackEvents")); + } + + @Test + public void trackEventsIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"trackEvents\": true}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertTrue(r.isTrackEvents()); + } + + @Test + public void trackEventsDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertFalse(r.isTrackEvents()); + } + + @Test + public void debugEventsUntilDateIsSerialized() { + final long date = 12345L; + final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, false, date, null); + final JsonObject json = r.getAsJsonObject(); + assertEquals(new JsonPrimitive(date), json.get("debugEventsUntilDate")); + } + + @Test + public void debugEventsUntilDateIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"debugEventsUntilDate\": 12345}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertEquals(new Long(12345L), r.getDebugEventsUntilDate()); + } + + @Test + public void debugEventsUntilDateDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + 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 UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, reason); + final JsonObject json = 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 UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertEquals(reason, r.getReason()); + } + } + + @Test + public void reasonDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + assertNull(r.getReason()); + } + + @Test + public void emptyPropertiesAreNotSerialized() { + final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, null); + final JsonObject json = r.getAsJsonObject(); + assertEquals(ImmutableSet.of("value", "version", "flagVersion"), json.keySet()); + } +} 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..ebe8d5be 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 @@ -135,6 +135,9 @@ 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 .url(uri) @@ -144,6 +147,9 @@ private Request getDefaultRequest(LDUser user) { 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); 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..c4a4d191 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 @@ -61,6 +61,8 @@ public class LDConfig { private final boolean inlineUsersInEvents; + private final boolean evaluationReasons; + LDConfig(Map mobileKeys, Uri baseUri, Uri eventsUri, @@ -76,7 +78,8 @@ public class LDConfig { boolean useReport, boolean allAttributesPrivate, Set privateAttributeNames, - boolean inlineUsersInEvents) { + boolean inlineUsersInEvents, + boolean evaluationReasons) { this.mobileKeys = mobileKeys; this.baseUri = baseUri; @@ -94,6 +97,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 +190,10 @@ public boolean inlineUsersInEvents() { return inlineUsersInEvents; } + public boolean isEvaluationReasons() { + return evaluationReasons; + } + public static class Builder { private String mobileKey; private Map secondaryMobileKeys; @@ -209,6 +217,7 @@ 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. @@ -418,6 +427,22 @@ public LDConfig.Builder setInlineUsersInEvents(boolean 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; + } + public LDConfig build() { if (!stream) { if (pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { @@ -474,7 +499,8 @@ public LDConfig build() { useReport, allAttributesPrivate, privateAttributeNames, - inlineUsersInEvents); + inlineUsersInEvents, + evaluationReasons); } } } 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..6dcccb30 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 @@ -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/response/UserFlagResponse.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java index 04f7eb62..e7e854be 100644 --- 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 @@ -110,6 +110,7 @@ public EvaluationReason getReason() { @Override public JsonObject getAsJsonObject() { JsonObject object = new JsonObject(); + object.add("value", value); object.add("version", new JsonPrimitive(version)); object.add("flagVersion", new JsonPrimitive(flagVersion)); if (variation != null) { diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java index 0c7d2de4..9db002c2 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java @@ -14,8 +14,8 @@ public static UserFlagResponse parseFlag(JsonObject o, String key) { if (o == null) { return null; } + JsonElement valueElement = o.get("value"); JsonPrimitive versionElement = getPrimitive(o, "version"); - JsonPrimitive valueElement = getPrimitive(o, "value"); JsonPrimitive flagVersionElement = getPrimitive(o, "flagVersion"); JsonPrimitive variationElement = getPrimitive(o, "variation"); JsonPrimitive trackEventsElement = getPrimitive(o, "trackEvents"); From 503cfcb468390ed67e78b8f48620f28ea35571c9 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 14 Feb 2019 16:54:27 +0000 Subject: [PATCH 053/152] Changed shared preferences store system to user a single FlagStore system that holds all the information on a flag to prevent issues arising from unsynchronized separate stores for flag meta-data and values. --- .../launchdarkly/android/UserManagerTest.java | 237 ++++++----- .../sharedprefs/SharedPrefsFlagStoreTest.java | 200 +++++++++ ...UserFlagResponseSharedPreferencesTest.java | 197 --------- .../response/UserFlagResponseTest.java | 90 ++--- .../android/ConnectivityReceiver.java | 4 + .../com/launchdarkly/android/LDClient.java | 381 ++++++++---------- .../com/launchdarkly/android/Migration.java | 113 ++++++ .../android/UserLocalSharePreferences.java | 371 ----------------- .../com/launchdarkly/android/UserManager.java | 292 +++++--------- .../launchdarkly/android/flagstore/Flag.java | 120 ++++++ .../android/flagstore/FlagInterface.java | 17 + .../android/flagstore/FlagStore.java | 19 + .../android/flagstore/FlagStoreManager.java | 14 + .../flagstore/FlagStoreUpdateType.java | 5 + .../android/flagstore/FlagUpdate.java | 8 + .../flagstore/StoreUpdatedListener.java | 5 + .../sharedprefs/SharedPrefsFlagStore.java | 150 +++++++ .../SharedPrefsFlagStoreManager.java | 193 +++++++++ .../response/BaseUserSharedPreferences.java | 2 +- .../android/response/DeleteFlagResponse.java | 28 ++ .../android/response/FlagResponse.java | 34 -- .../FlagResponseSharedPreferences.java | 24 -- .../android/response/FlagResponseStore.java | 13 - .../android/response/GsonCache.java | 114 ++++++ .../android/response/UserFlagResponse.java | 135 ------- .../UserFlagResponseSharedPreferences.java | 78 ---- .../response/UserFlagResponseStore.java | 30 -- .../DeleteFlagResponseInterpreter.java | 33 -- .../interpreter/FlagResponseInterpreter.java | 10 - .../PatchFlagResponseInterpreter.java | 28 -- .../PingFlagResponseInterpreter.java | 46 --- .../PutFlagResponseInterpreter.java | 38 -- .../interpreter/ResponseInterpreter.java | 10 - .../interpreter/UserFlagResponseParser.java | 103 ----- 34 files changed, 1420 insertions(+), 1722 deletions(-) create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java delete mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/Flag.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagInterface.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStore.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreManager.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreUpdateType.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagUpdate.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/StoreUpdatedListener.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/DeleteFlagResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseSharedPreferences.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseStore.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/GsonCache.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferences.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseStore.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/FlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PatchFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PingFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PutFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/ResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java 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 db0285a0..98d46212 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,14 @@ 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.Gson; 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 +29,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; @@ -59,21 +60,39 @@ 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 { 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 @@ -81,12 +100,12 @@ 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"); @@ -102,14 +121,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); @@ -125,7 +144,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()); @@ -147,32 +166,30 @@ 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(); - 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(); } @@ -182,8 +199,8 @@ public void TestDeleteForInvalidResponse() throws ExecutionException, Interrupte 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(); @@ -198,9 +215,7 @@ public void TestDeleteForInvalidResponse() throws ExecutionException, Interrupte @Test public void TestDeleteWithVersion() throws ExecutionException, InterruptedException { - userManager.clearFlagResponseSharedPreferences(); - - Future future = setUser("userKey", new JsonObject()); + Future future = setUserClear("userKey", new JsonObject()); future.get(); String json = "{\n" + @@ -214,109 +229,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(); - 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()); + 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.getStoredFlagResponse("flag1").getVersion()); + 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.getStoredFlagResponse("flag1").getVersion()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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()); + Future future = setUserClear("userKey", new JsonObject()); future.get(); String json = "{\n" + @@ -331,26 +345,29 @@ 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.getStoredFlagResponse("stringFlag1").getFlagVersion()); - assertEquals(125, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersionForEvents()); + 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.getStoredFlagResponse("stringFlag1").getVersion()); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getFlagVersion()); - assertEquals(126, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersionForEvents()); + 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.getStoredFlagResponse("stringFlag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersionForEvents()); + 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 @@ -358,8 +375,8 @@ public void TestPatchForInvalidResponse() throws ExecutionException, Interrupted 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(); @@ -376,9 +393,9 @@ public void TestPatchForInvalidResponse() throws ExecutionException, Interrupted 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,15 +420,15 @@ 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 @@ -419,8 +436,8 @@ public void TestPutForInvalidResponse() throws ExecutionException, InterruptedEx 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 +461,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 +491,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/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java new file mode 100644 index 00000000..47abae89 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java @@ -0,0 +1,200 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.flagstore.Flag; +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.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.Collections; + +@RunWith(AndroidJUnit4.class) +public class SharedPrefsFlagStoreTest { + + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + @Test + public void savesVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), null, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 12, 0); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getVersion(), null); + } + + @Test + public void deletesVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + 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 Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag updatedKey1 = new Flag(key1.getKey(), key1.getValue(), 15, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + + flagStore.applyFlagUpdate(updatedKey1); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 15, 0); + } + + @Test + public void clearsFlags() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + flagStore.clear(); + + Assert.assertNull(flagStore.getFlag(key1.getKey())); + Assert.assertNull(flagStore.getFlag(key2.getKey())); + Assert.assertEquals(0, flagStore.getAllFlags().size(), 0); + } + + @Test + public void savesVariation() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, null, null, null); + final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVariation(), 16, 0); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getVariation(), 23, 0); + Assert.assertEquals(flagStore.getFlag(key3.getKey()).getVariation(), null); + } + + @Test + public void savesTrackEvents() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, false, 123456789L, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, true, 987654321L, null); + final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getTrackEvents(), false); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getTrackEvents(), true); + Assert.assertFalse(flagStore.getFlag(key3.getKey()).getTrackEvents()); + } + + @Test + public void savesDebugEventsUntilDate() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, false, 123456789L, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, true, 987654321L, null); + final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + //noinspection ConstantConditions + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getDebugEventsUntilDate(), 123456789L, 0); + //noinspection ConstantConditions + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getDebugEventsUntilDate(), 987654321L, 0); + Assert.assertNull(flagStore.getFlag(key3.getKey()).getDebugEventsUntilDate()); + } + + + @Test + public void savesFlagVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), null, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 12, 0); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getFlagVersion(), null); + } + + @Test + public void deletesFlagVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + 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 Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); + final Flag updatedKey1 = new Flag(key1.getKey(), key1.getValue(), null, 15, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + + flagStore.applyFlagUpdate(updatedKey1); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 15, 0); + } + + @Test + public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() { + final Flag withFlagVersion = new Flag("withFlagVersion", new JsonPrimitive(true), 12, 13, null, null, null, null); + final Flag withOnlyVersion = new Flag("withOnlyVersion", new JsonPrimitive(true), 12, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(withFlagVersion, withOnlyVersion)); + + Assert.assertEquals(flagStore.getFlag(withFlagVersion.getKey()).getVersionForEvents(), 13, 0); + Assert.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 + // UserFlagResponseTest. + final EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); + final Flag flag1 = new Flag("key1", new JsonPrimitive(true), 11, + 1, 1, null, null, reason); + final Flag flag2 = new Flag("key2", new JsonPrimitive(true), 11, + 1, 1, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(flag1, flag2)); + + Assert.assertEquals(reason, flagStore.getFlag(flag1.getKey()).getReason()); + Assert.assertNull(flagStore.getFlag(flag2.getKey()).getReason()); + } +} 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 994e17ea..00000000 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java +++ /dev/null @@ -1,197 +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.EvaluationReason; -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.getStoredFlagResponse(key1.getKey()).getVersion(), 12, 0); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getVersion(), -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.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); - } - - @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.getStoredFlagResponse(key1.getKey()).getVersion(), 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.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key2.getKey())); - Assert.assertEquals(0, versionSharedPreferences.getLength(), 0); - } - - @Test - public void savesVariation() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, null, null, null); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, null, 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(16, versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getVariation(), 0); - Assert.assertEquals(23, versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getVariation(),0); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).getVariation()); - } - - @Test - public void savesTrackEvents() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L, null); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L, 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.getStoredFlagResponse(key1.getKey()).isTrackEvents(), false); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).isTrackEvents(), true); - Assert.assertFalse(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).isTrackEvents()); - } - - @Test - public void savesDebugEventsUntilDate() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L, null); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L, 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)); - - //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getDebugEventsUntilDate(), 123456789L, 0); - //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getDebugEventsUntilDate(), 987654321L, 0); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).getDebugEventsUntilDate()); - } - - - @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.getStoredFlagResponse(key1.getKey()).getFlagVersion(), 12, 0); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getFlagVersion(), -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.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); - } - - @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.getStoredFlagResponse(key1.getKey()).getFlagVersion(), 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.getStoredFlagResponse(withFlagVersion.getKey()).getVersionForEvents(), 13, 0); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(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 - // UserFlagResponseTest. - final EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); - final UserFlagResponse flag1 = new UserFlagResponse("key1", new JsonPrimitive(true), 11, - 1, 1, null, null, reason); - final UserFlagResponse flag2 = new UserFlagResponse("key2", new JsonPrimitive(true), 11, - 1, 1, null, null, null); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(flag1, flag2)); - - Assert.assertEquals(reason, versionSharedPreferences.getStoredFlagResponse(flag1.getKey()).getReason()); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(flag2.getKey()).getReason()); - } -} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java index 69ab653f..296fc6d1 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java @@ -6,7 +6,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.android.EvaluationReason; -import com.launchdarkly.android.response.interpreter.UserFlagResponseParser; +import com.launchdarkly.android.flagstore.Flag; import org.junit.Test; @@ -14,11 +14,12 @@ 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 UserFlagResponseTest { - private static final Gson gson = new Gson(); + private static final Gson gson = GsonCache.getGson(); private static final Map TEST_REASONS = ImmutableMap.builder() .put(EvaluationReason.off(), "{\"kind\": \"OFF\"}") @@ -31,148 +32,140 @@ public class UserFlagResponseTest { @Test public void valueIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes")); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), null, null, null, null, null, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive("yes"), json.get("value")); } @Test public void valueIsDeserialized() { final String jsonStr = "{\"value\": \"yes\"}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertEquals(new JsonPrimitive("yes"), r.getValue()); } @Test public void versionIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(99), json.get("version")); } @Test public void versionIsDeserialized() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertEquals(99, r.getVersion()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNotNull(r.getVersion()); + assertEquals(99, (int) r.getVersion()); } @Test public void flagVersionIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertEquals(100, r.getFlagVersion()); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertEquals(-1, r.getFlagVersion()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getFlagVersion()); } @Test public void variationIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, null, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, null, null, null); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertEquals(new Integer(2), r.getVariation()); } @Test public void variationDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertNull(r.getVariation()); } @Test public void trackEventsIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, true, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, true, null, null); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertTrue(r.isTrackEvents()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertTrue(r.getTrackEvents()); } @Test public void trackEventsDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertFalse(r.isTrackEvents()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertFalse(r.getTrackEvents()); } @Test public void debugEventsUntilDateIsSerialized() { final long date = 12345L; - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, false, date, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, false, date, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(date), json.get("debugEventsUntilDate")); } @Test public void debugEventsUntilDateIsDeserialized() { final String jsonStr = "{\"version\": 99, \"debugEventsUntilDate\": 12345}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertEquals(new Long(12345L), r.getDebugEventsUntilDate()); } @Test public void debugEventsUntilDateDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertNull(r.getDebugEventsUntilDate()); } @Test public void reasonIsSerialized() { - for (Map.Entry e: TEST_REASONS.entrySet()) { + 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 UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, reason); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, reason); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(expectedJson, json.get("reason")); } } @Test public void reasonIsDeserialized() { - for (Map.Entry e: TEST_REASONS.entrySet()) { + 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 UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(json, Flag.class); assertEquals(reason, r.getReason()); } } @@ -180,15 +173,14 @@ public void reasonIsDeserialized() { @Test public void reasonDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertNull(r.getReason()); } @Test public void emptyPropertiesAreNotSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, null); - final JsonObject json = r.getAsJsonObject(); - assertEquals(ImmutableSet.of("value", "version", "flagVersion"), json.keySet()); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(ImmutableSet.of("key", "trackEvents", "value", "version", "flagVersion"), json.keySet()); } } 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/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index a497d84a..04d1f610 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 @@ -18,24 +18,23 @@ 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.Gson; 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.FlagResponse; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagInterface; +import com.launchdarkly.android.response.GsonCache; import com.launchdarkly.android.response.SummaryEventSharedPreferences; import com.google.android.gms.security.ProviderInstaller; 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; @@ -152,7 +151,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()); @@ -310,103 +309,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. * @@ -497,7 +399,23 @@ public Void apply(List input) { */ @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.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { + result.put(flag.getKey(), jsonVal.getAsBoolean()); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { + // TODO distinguish ints? + result.put(flag.getKey(), jsonVal.getAsFloat()); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { + result.put(flag.getKey(), jsonVal.getAsString()); + } else { + // TODO + result.put(flag.getKey(), GsonCache.getGson().toJson(jsonVal)); + } + } + return result; } /** @@ -508,35 +426,38 @@ public Void apply(List input) { *

  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public Boolean boolVariation(String flagKey, Boolean fallback) { - Boolean result = null; - if (flagKey != null) { - try { - result = (Boolean) userManager.getCurrentUserSharedPrefs().getAll().get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get boolean flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } - } else { - Timber.e("Attempted to get boolean flag with a default null value for key. Returning fallback: %s", fallback); + if (flagKey == null) { + Timber.e("Attempted to get boolean flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - result = result == null ? fallback : result; - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, null); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + Boolean result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent boolean flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get boolean flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { + result = jsonVal.getAsBoolean(); + } else { + Timber.e("Attempted to get boolean flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("boolVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -549,34 +470,38 @@ public Boolean boolVariation(String flagKey, Boolean fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public Integer intVariation(String flagKey, Integer fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - Integer result = fallback; if (flagKey == null) { - Timber.e("Attempted to get integer flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - result = (Integer) sharedPrefs.get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get integer flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } + Timber.e("Attempted to get integer flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + Integer result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent integer flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get integer flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { + result = jsonVal.getAsInt(); + } else { + Timber.e("Attempted to get integer flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("intVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -589,34 +514,38 @@ public Integer intVariation(String flagKey, Integer fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public Float floatVariation(String flagKey, Float fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - Float result = fallback; if (flagKey == null) { - Timber.e("Attempted to get float flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - result = (Float) sharedPrefs.get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get float flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } + Timber.e("Attempted to get float flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + Float result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent float flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get float flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { + result = jsonVal.getAsFloat(); + } else { + Timber.e("Attempted to get float flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("floatVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -629,34 +558,38 @@ public Float floatVariation(String flagKey, Float fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public String stringVariation(String flagKey, String fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - String result = fallback; if (flagKey == null) { - Timber.e("Attempted to get string flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - result = (String) sharedPrefs.get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get string flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } + Timber.e("Attempted to get string flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + String result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent string flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get string flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { + result = jsonVal.getAsString(); + } else { + Timber.e("Attempted to get string flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("stringVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -669,29 +602,36 @@ public String stringVariation(String flagKey, String fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public JsonElement jsonVariation(String flagKey, JsonElement fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - JsonElement result = fallback; if (flagKey == null) { - Timber.e("Attempted to get string flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - String stringResult = (String) sharedPrefs.get(flagKey); - result = new JsonParser().parse(stringResult); - } 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 (JsonSyntaxException jse) { - Timber.e(jse, "Attempted to get json flag from string flag for key: %s Returning fallback: %s", flagKey, fallback); + Timber.e("Attempted to get json flag with a null value for key. Returning fallback: %s", fallback); + return fallback; + } + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + JsonElement result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent json flag for key: %s Returning fallback: %s", flagKey, fallback); + } else { + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { // TODO, return null, or fallback? can jsonVal even be null (as opposed to jsonNull)? + Timber.e("Attempted to get json flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else { + result = jsonVal; } } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - updateSummaryEvents(flagKey, flag, result, fallback); - sendFlagRequestEvent(flagKey, flag, result, fallback); + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : fallback; + JsonElement val = result == null ? JsonNull.INSTANCE : result; + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("jsonVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -861,17 +801,20 @@ void startForegroundUpdating() { } } - private void sendFlagRequestEvent(String flagKey, FlagResponse flag, JsonElement value, JsonElement fallback) { - int version = flag == null ? -1 : flag.getVersionForEvents(); - Integer variation = flag == null ? null : flag.getVariation(); - if (flag != null && flag.isTrackEvents()) { + private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, JsonElement fallback) { + 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)); } else { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser().getKeyAsString(), value, fallback, version, variation)); } } else { - Long debugEventsUntilDate = flag == null ? null : flag.getDebugEventsUntilDate(); + Long debugEventsUntilDate = flag.getDebugEventsUntilDate(); if (debugEventsUntilDate != null) { long serverTimeMs = eventProcessor.getCurrentTimeMs(); if (debugEventsUntilDate > System.currentTimeMillis() && debugEventsUntilDate > serverTimeMs) { @@ -904,15 +847,21 @@ 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, FlagResponse flag, JsonElement result, JsonElement fallback) { - int version = flag == null ? -1 : flag.getVersionForEvents(); - Integer variation = flag == null ? null : flag.getVariation(); - boolean isUnknown = !userManager.getFlagResponseSharedPreferences().containsKey(flagKey); + private void updateSummaryEvents(String flagKey, Flag flag, JsonElement result, JsonElement fallback) { + if (flag == null) { + userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, -1, -1, true); + } else { + int version = flag.getVersionForEvents(); + Integer variation = flag.getVariation(); + if (variation == null) + variation = -1; - userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, version, variation, isUnknown); + userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, version, variation, false); + } } /** 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..ee761081 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java @@ -0,0 +1,113 @@ +package com.launchdarkly.android; + +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; + +import java.io.File; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.Map; + +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.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(); + } + +} 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..bd4277e2 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,25 +12,20 @@ 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.google.gson.reflect.TypeToken; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagStore; +import com.launchdarkly.android.flagstore.FlagStoreManager; +import com.launchdarkly.android.flagstore.sharedprefs.SharedPrefsFlagStoreManager; +import com.launchdarkly.android.response.DeleteFlagResponse; +import com.launchdarkly.android.response.GsonCache; 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 java.util.ArrayList; import java.util.Collection; -import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -50,13 +42,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 +57,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); 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 +68,8 @@ LDUser getCurrentUser() { return currentUser; } - SharedPreferences getCurrentUserSharedPrefs() { - return userLocalSharedPreferences.getCurrentUserSharedPrefs(); - } - - FlagResponseSharedPreferences getFlagResponseSharedPreferences() { - return flagResponseSharedPreferences; + FlagStore getCurrentUserFlagStore() { + return flagStoreManager.getCurrentUserStore(); } SummaryEventSharedPreferences getSummaryEventSharedPreferences() { @@ -108,7 +87,7 @@ 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 +105,7 @@ 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(); +// syncCurrentUserToActiveUserAndLog(); } }, MoreExecutors.directExecutor()); @@ -140,17 +119,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,26 +133,25 @@ 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 { + Map flagMap; + final ArrayList flags = new ArrayList<>(); + flagMap = GsonCache.getGson().fromJson(flagsJson, new TypeToken>() {}.getType()); + for (Map.Entry flagEntry : flagMap.entrySet()) { + Flag flag = flagEntry.getValue(); + flag.setKey(flagEntry.getKey()); + flags.add(flag); + } + flagStoreManager.getCurrentUserStore().clearAndApplyFlagUpdates(flags); + } catch (Exception e) { - private void syncCurrentUserToActiveUserAndLog() { - userLocalSharedPreferences.syncCurrentUserToActiveUser(); - userLocalSharedPreferences.logCurrentUserFlags(); + } } private static String userBase64ToJson(String base64) { @@ -190,165 +163,80 @@ 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 Callable() { + @Override + public Void call() { + 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; } - return 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); - } - return null; + try { + Map flagMap; + final ArrayList flags = new ArrayList<>(); + flagMap = GsonCache.getGson().fromJson(json, new TypeToken>() {}.getType()); + for (Map.Entry flagEntry : flagMap.entrySet()) { + Flag flag = flagEntry.getValue(); + flag.setKey(flagEntry.getKey()); + flags.add(flag); } - }); + ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); + return service.submit(new Callable() { + @Override + public Void call() { + initialized = true; + Timber.d("PUT for user key: %s", currentUser.getKey()); + flagStoreManager.getCurrentUserStore().clearAndApplyFlagUpdates(flags); + return 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 Callable() { + @Override + public Void call() { + 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; } - 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); - } + }); + } catch (Exception ex) { + Timber.d(ex, "Invalid PATCH payload: %s", json); + // In future should this be an immediateFailedFuture? + return Futures.immediateFuture(null); } - 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); - } - } - - 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/flagstore/Flag.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/Flag.java new file mode 100644 index 00000000..3fc286c2 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/Flag.java @@ -0,0 +1,120 @@ +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 void setKey(@NonNull String key) { + this.key = key; + } + + public JsonElement getValue() { + return value; + } + + public void setValue(JsonElement value) { + this.value = value; + } + + public Integer getVersion() { + return version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + public Integer getFlagVersion() { + return flagVersion; + } + + public void setFlagVersion(Integer flagVersion) { + this.flagVersion = flagVersion; + } + + public Integer getVariation() { + return variation; + } + + public void setVariation(Integer variation) { + this.variation = variation; + } + + public boolean getTrackEvents() { + return trackEvents == null ? false : trackEvents; + } + + public void setTrackEvents(Boolean trackEvents) { + this.trackEvents = trackEvents; + } + + public Long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + public void setDebugEventsUntilDate(Long debugEventsUntilDate) { + this.debugEventsUntilDate = debugEventsUntilDate; + } + + @Override + public EvaluationReason getReason() { + return reason; + } + + public void setReason(EvaluationReason reason) { + this.reason = 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..47cb6aff --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagInterface.java @@ -0,0 +1,17 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +import com.google.gson.JsonElement; +import com.launchdarkly.android.EvaluationReason; + +public interface FlagInterface { + + @NonNull + String getKey(); + JsonElement getValue(); + Integer getVersion(); + Integer getFlagVersion(); + Integer getVariation(); + 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..ba2e2423 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStore.java @@ -0,0 +1,19 @@ +package com.launchdarkly.android.flagstore; + +import java.util.List; + +import javax.annotation.Nullable; + +public interface FlagStore { + + void clear(); + boolean containsKey(String key); + @Nullable + Flag getFlag(String flagKey); + void applyFlagUpdate(FlagUpdate flagUpdate); + void applyFlagUpdates(List flagUpdates); + void clearAndApplyFlagUpdates(List flagUpdates); + List getAllFlags(); + void registerOnStoreUpdatedListener(StoreUpdatedListener storeUpdatedListener); + void unregisterOnStoreUpdatedListener(); +} 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..83b11df8 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreManager.java @@ -0,0 +1,14 @@ +package com.launchdarkly.android.flagstore; + +import com.launchdarkly.android.FeatureFlagChangeListener; + +import java.util.Collection; + +public interface FlagStoreManager { + + void switchToUser(String userKey); + FlagStore getCurrentUserStore(); + void registerListener(String key, FeatureFlagChangeListener listener); + void unRegisterListener(String key, FeatureFlagChangeListener listener); + 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..bb3dde41 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreUpdateType.java @@ -0,0 +1,5 @@ +package com.launchdarkly.android.flagstore; + +public enum FlagStoreUpdateType { + FLAG_DELETED, FLAG_UPDATED, 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..e68a871b --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagUpdate.java @@ -0,0 +1,8 @@ +package com.launchdarkly.android.flagstore; + +public interface FlagUpdate { + + Flag updateFlag(Flag before); + 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..cb0017a9 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/StoreUpdatedListener.java @@ -0,0 +1,5 @@ +package com.launchdarkly.android.flagstore; + +public interface StoreUpdatedListener { + 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..dab96655 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java @@ -0,0 +1,150 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Pair; + +import com.google.gson.Gson; +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.response.GsonCache; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +import timber.log.Timber; + +public class SharedPrefsFlagStore implements FlagStore { + + private SharedPreferences sharedPreferences; + private StoreUpdatedListener storeUpdatedListener; + + public SharedPrefsFlagStore(Application application, String name) { + this.sharedPreferences = application.getSharedPreferences(name, Context.MODE_PRIVATE); + } + + @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(SharedPreferences.Editor editor, FlagUpdate flagUpdate) { + String flagKey = flagUpdate.flagToUpdate(); + 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; + } + + // TODO synchronize listeners + @Override + public void applyFlagUpdate(FlagUpdate flagUpdate) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + Pair update = applyFlagUpdateNoCommit(editor, flagUpdate); + editor.apply(); + if (update != null && storeUpdatedListener != null) { + storeUpdatedListener.onStoreUpdate(update.first, update.second); + } + } + + private ArrayList> applyFlagUpdatesNoCommit(SharedPreferences.Editor editor, List flagUpdates) { + ArrayList> updates = new ArrayList<>(); + for (FlagUpdate flagUpdate : flagUpdates) { + Pair update = applyFlagUpdateNoCommit(editor, flagUpdate); + if (update != null) { + updates.add(update); + } + } + return updates; + } + + private void informListenersOfUpdateList(List> updates) { + 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 = applyFlagUpdatesNoCommit(editor, flagUpdates); + editor.apply(); + informListenersOfUpdateList(updates); + } + + @Override + public void clearAndApplyFlagUpdates(List flagUpdates) { + sharedPreferences.edit().clear().apply(); + applyFlagUpdates(flagUpdates); + } + + @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) { + this.storeUpdatedListener = storeUpdatedListener; + } + + @Override + public void unregisterOnStoreUpdatedListener() { + this.storeUpdatedListener = null; + } +} 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..78773df8 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java @@ -0,0 +1,193 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; + +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.FlagStoreManager; +import com.launchdarkly.android.flagstore.FlagStoreUpdateType; +import com.launchdarkly.android.flagstore.StoreUpdatedListener; + +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; + +public class SharedPrefsFlagStoreManager implements FlagStoreManager, StoreUpdatedListener { + + private static final String SHARED_PREFS_BASE_KEY = "LaunchDarkly-"; + private static final int MAX_USERS = 5; + + private final Application application; + private String mobileKey; + + private final SharedPreferences usersSharedPrefs; + private FlagStore currentFlagStore; + + private final Multimap listeners; + + public SharedPrefsFlagStoreManager(Application application, String mobileKey) { + this.application = application; + this.mobileKey = mobileKey; + 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 = new SharedPrefsFlagStore(application, sharedPrefsKeyForUser(userKey)); + currentFlagStore.registerOnStoreUpdatedListener(this); + + usersSharedPrefs.edit() + .putLong(userKey, 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(); + } + } + + /** + * Completely deletes a user's saved flag settings and the remaining empty SharedPreferences xml file. + * + * @param userKey key the user's flag settings are stored under + */ + @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 loadSharedPrefsForUser(String userKey) { + Timber.d("Using SharedPreferences key: [%s]", sharedPrefsKeyForUser(userKey)); + return application.getSharedPreferences(sharedPrefsKeyForUser(userKey), Context.MODE_PRIVATE); + } + + private String sharedPrefsKeyForUser(String userKey) { + return SHARED_PREFS_BASE_KEY + mobileKey + userKey + "-flags"; + } + + @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 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) + "]"; + } + + @Override + public void onStoreUpdate(final String flagKey, final FlagStoreUpdateType flagStoreUpdateType) { + if (Looper.myLooper() == Looper.getMainLooper()) { + synchronized (listeners) { + if (flagStoreUpdateType != FlagStoreUpdateType.FLAG_DELETED) { + for (FeatureFlagChangeListener listener : listeners.get(flagKey)) { + listener.onFeatureFlagChange(flagKey); + } + } else { + listeners.removeAll(flagKey); + } + } + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + onStoreUpdate(flagKey, flagStoreUpdateType); + } + }); + } + } + + class EntryComparator implements Comparator> { + @Override + public int compare(Map.Entry lhs, Map.Entry rhs) { + return (int) (lhs.getValue() - rhs.getValue()); + } + } + + public Collection getListenersByKey(String key) { + synchronized (listeners) { + return listeners.get(key); + } + } +} 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 index 4d06cc96..5130e27f 100644 --- 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 @@ -35,7 +35,7 @@ JsonElement extractValueFromPreferences(String flagResponseKey, String keyOfValu @SuppressLint("ApplySharedPref") @Nullable - JsonObject getValueAsJsonObject(String flagResponseKey) { + public JsonObject getValueAsJsonObject(String flagResponseKey) { String storedFlag; try { storedFlag = sharedPreferences.getString(flagResponseKey, 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..065c9984 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/DeleteFlagResponse.java @@ -0,0 +1,28 @@ +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; + } + + @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 df4e8392..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.launchdarkly.android.response; - -import com.launchdarkly.android.EvaluationReason; -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(); - - int getVersionForEvents(); - - Integer getVariation(); - - boolean isTrackEvents(); - - Long getDebugEventsUntilDate(); - - EvaluationReason getReason(); - - 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 edc0ef58..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseSharedPreferences.java +++ /dev/null @@ -1,24 +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); - - FlagResponse getStoredFlagResponse(String flagResponseKey); - - boolean containsKey(String key); -} 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/GsonCache.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/GsonCache.java new file mode 100644 index 00000000..b90db6a3 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/GsonCache.java @@ -0,0 +1,114 @@ +package com.launchdarkly.android.response; + +import android.support.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +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; + +public class GsonCache { + + private static Gson gson; + + public static Gson getGson() { + if (gson == null) { + gson = createGson(); + } + return gson; + } + + @Nullable + private static > T parseEnum(Class c, String name, T fallback) { + try { + return Enum.valueOf(c, name); + } catch (IllegalArgumentException e) { + return fallback; + } + } + + private static Gson createGson() { + GsonBuilder gsonBuilder = new GsonBuilder(); + JsonSerializer serializer = new JsonSerializer() { + @Override + public JsonElement serialize(EvaluationReason src, Type typeOfSrc, JsonSerializationContext context) { + if (src instanceof EvaluationReason.Off) { + return context.serialize(src, EvaluationReason.Off.class); + } else if (src instanceof EvaluationReason.Fallthrough) { + return context.serialize(src, EvaluationReason.Fallthrough.class); + } else if (src instanceof EvaluationReason.TargetMatch) { + return context.serialize(src, EvaluationReason.TargetMatch.class); + } else if (src instanceof EvaluationReason.RuleMatch) { + return context.serialize(src, EvaluationReason.RuleMatch.class); + } else if (src instanceof EvaluationReason.PrerequisiteFailed) { + return context.serialize(src, EvaluationReason.PrerequisiteFailed.class); + } else if (src instanceof EvaluationReason.Error) { + return context.serialize(src, EvaluationReason.Error.class); + } else if (src instanceof EvaluationReason.Unknown) { + return context.serialize(src, EvaluationReason.Unknown.class); + } + return null; + } + }; + JsonDeserializer deserializer = new JsonDeserializer() { + @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; + } + }; + gsonBuilder.registerTypeAdapter(EvaluationReason.class, serializer); + gsonBuilder.registerTypeAdapter(EvaluationReason.class, deserializer); + return gsonBuilder.create(); + } +} 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 e7e854be..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.launchdarkly.android.response; - -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.android.EvaluationReason; - -/** - * Farhan - * 2018-01-30 - */ -public class UserFlagResponse implements FlagResponse { - private static Gson gson = new Gson(); - - @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; - - @Nullable - private final EvaluationReason reason; - - public UserFlagResponse(@NonNull String key, @Nullable JsonElement value, int version, int flagVersion, @Nullable Integer variation, @Nullable Boolean trackEvents, @Nullable Long debugEventsUntilDate, @Nullable EvaluationReason reason) { - this.key = key; - this.value = value; - this.version = version; - this.flagVersion = flagVersion; - this.variation = variation; - this.trackEvents = trackEvents == null ? false : trackEvents.booleanValue(); - this.debugEventsUntilDate = debugEventsUntilDate; - this.reason = reason; - } - - public UserFlagResponse(String key, JsonElement value) { - this(key, value, -1, -1, null, null, null, null); - } - - public UserFlagResponse(String key, JsonElement value, int version, int flagVersion) { - this(key, value, version, flagVersion, null, 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; - } - - @Override - public int getVersionForEvents() { - return flagVersion > 0 ? flagVersion : version; - } - - @Nullable - @Override - public Integer getVariation() { - return variation; - } - - @Override - public boolean isTrackEvents() { - return trackEvents; - } - - @Nullable - @Override - public Long getDebugEventsUntilDate() { - return debugEventsUntilDate; - } - - @Nullable - @Override - public EvaluationReason getReason() { - return reason; - } - - @Override - public JsonObject getAsJsonObject() { - JsonObject object = new JsonObject(); - object.add("value", value); - object.add("version", new JsonPrimitive(version)); - object.add("flagVersion", new JsonPrimitive(flagVersion)); - if (variation != null) { - object.add("variation", new JsonPrimitive(variation)); - } - if (trackEvents) { - object.add("trackEvents", new JsonPrimitive(true)); - } - if (debugEventsUntilDate != null) { - object.add("debugEventsUntilDate", new JsonPrimitive(debugEventsUntilDate)); - } - if (reason != null) { - object.add("reason", gson.toJsonTree(reason)); - } - 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 cf06f08d..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferences.java +++ /dev/null @@ -1,78 +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 com.google.gson.JsonObject; -import com.launchdarkly.android.response.interpreter.UserFlagResponseParser; - -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) { - FlagResponse storedFlag = getStoredFlagResponse(flagResponse.getKey()); - if (storedFlag != null) { - return storedFlag.getVersion() < 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 FlagResponse getStoredFlagResponse(String key) { - JsonObject jsonObject = getValueAsJsonObject(key); - return jsonObject == null ? null : UserFlagResponseParser.parseFlag(jsonObject, key); - } - - @Override - public boolean containsKey(String key) { - return sharedPreferences.contains(key); - } - - @VisibleForTesting - int getLength() { - return sharedPreferences.getAll().size(); - } -} 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/interpreter/DeleteFlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java deleted file mode 100644 index 90f46003..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java +++ /dev/null @@ -1,33 +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 implements FlagResponseInterpreter { - - @Nullable - @Override - public FlagResponse apply(@Nullable JsonObject input) { - if (input != null) { - JsonElement keyElement = input.get("key"); - JsonElement versionElement = input.get("version"); - int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() - ? versionElement.getAsInt() - : -1; - - if (keyElement != null) { - String key = keyElement.getAsJsonPrimitive().getAsString(); - return new UserFlagResponse(key, null, version, -1, -1, false, null, null); - } - } - 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 ad6cf6ef..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PatchFlagResponseInterpreter.java +++ /dev/null @@ -1,28 +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 javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class PatchFlagResponseInterpreter implements FlagResponseInterpreter { - - @Nullable - @Override - public FlagResponse apply(@Nullable JsonObject input) { - if (input != null) { - JsonElement keyElement = input.get("key"); - - if (keyElement != null) { - String key = keyElement.getAsJsonPrimitive().getAsString(); - return UserFlagResponseParser.parseFlag(input, key); - } - } - 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 95430ec6..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PingFlagResponseInterpreter.java +++ /dev/null @@ -1,46 +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 implements FlagResponseInterpreter> { - - @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(); - - flagResponseList.add(UserFlagResponseParser.parseFlag(asJsonObject, key)); - } else { - flagResponseList.add(new UserFlagResponse(key, v)); - } - } - } - return flagResponseList; - } - - protected boolean isValueInsideObject(JsonElement element) { - return !element.isJsonNull() && element.isJsonObject() && element.getAsJsonObject().has("value"); - } -} 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 598a971c..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PutFlagResponseInterpreter.java +++ /dev/null @@ -1,38 +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 java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class PutFlagResponseInterpreter implements FlagResponseInterpreter> { - - @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) { - flagResponseList.add(UserFlagResponseParser.parseFlag(asJsonObject, key)); - } - } - } - 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/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java deleted file mode 100644 index 9db002c2..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import android.support.annotation.Nullable; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.android.EvaluationReason; -import com.launchdarkly.android.response.UserFlagResponse; - -public class UserFlagResponseParser { - - public static UserFlagResponse parseFlag(JsonObject o, String key) { - if (o == null) { - return null; - } - JsonElement valueElement = o.get("value"); - JsonPrimitive versionElement = getPrimitive(o, "version"); - JsonPrimitive flagVersionElement = getPrimitive(o, "flagVersion"); - JsonPrimitive variationElement = getPrimitive(o, "variation"); - JsonPrimitive trackEventsElement = getPrimitive(o, "trackEvents"); - JsonPrimitive debugEventsUntilDateElement = getPrimitive(o, "debugEventsUntilDate"); - JsonElement reasonElement = o.get("reason"); - int version = versionElement != null && versionElement.isNumber() - ? versionElement.getAsInt() - : -1; - Integer variation = variationElement != null && variationElement.isNumber() - ? variationElement.getAsInt() - : null; - int flagVersion = flagVersionElement != null && flagVersionElement.isNumber() - ? flagVersionElement.getAsInt() - : -1; - boolean trackEvents = trackEventsElement != null && trackEventsElement.isBoolean() - && trackEventsElement.getAsBoolean(); - Long debugEventsUntilDate = debugEventsUntilDateElement != null && debugEventsUntilDateElement.isNumber() - ? debugEventsUntilDateElement.getAsLong() - : null; - EvaluationReason reason = reasonElement != null && reasonElement.isJsonObject() - ? parseReason(reasonElement.getAsJsonObject()) - : null; - return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate, reason); - } - - @Nullable - private static JsonPrimitive getPrimitive(JsonObject o, String name) { - JsonElement e = o.get(name); - return e != null && e.isJsonPrimitive() ? e.getAsJsonPrimitive() : null; - } - - @Nullable - private static EvaluationReason parseReason(JsonObject o) { - 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; - } - } - return null; - } - - @Nullable - private static > T parseEnum(Class c, String name, T fallback) { - try { - return Enum.valueOf(c, name); - } catch (IllegalArgumentException e) { - return fallback; - } - } -} From 213dd47b4525b6c03ff16a677afbdeaa2e721dbb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 14 Feb 2019 16:20:01 -0800 Subject: [PATCH 054/152] add methods to get value with explanation; refactor existing variation methods --- .../com/launchdarkly/android/EventTest.java | 16 +- .../java/com/launchdarkly/android/Event.java | 21 +- .../com/launchdarkly/android/LDClient.java | 244 ++++-------------- .../android/LDClientInterface.java | 124 +++++++++ .../com/launchdarkly/android/ValueTypes.java | 92 +++++++ 5 files changed, 298 insertions(+), 199 deletions(-) create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/ValueTypes.java 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 df677d6f..e624ac13 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,15 +244,23 @@ public void testOptionalFieldsAreExcludedAppropriately() { LDUser user = builder.build(); - final FeatureRequestEvent hasVersionEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, 5, null); - 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); } 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 bc9f2682..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 @@ -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, - @Nullable Integer 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,15 +111,16 @@ class FeatureRequestEvent extends GenericEvent { */ FeatureRequestEvent(String key, String userKey, JsonElement value, JsonElement defaultVal, @IntRange(from=(0), to=(Integer.MAX_VALUE)) int version, - @Nullable Integer 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, @Nullable Integer variation) { + private void setOptionalValues(int version, @Nullable Integer variation, @Nullable EvaluationReason reason) { if (version != -1) { this.version = version; } else { @@ -127,13 +132,15 @@ private void setOptionalValues(int version, @Nullable Integer 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, @Nullable Integer 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/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index 04d1f610..51418595 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 @@ -18,13 +18,11 @@ 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.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.android.flagstore.Flag; -import com.launchdarkly.android.flagstore.FlagInterface; import com.launchdarkly.android.response.GsonCache; import com.launchdarkly.android.response.SummaryEventSharedPreferences; import com.google.android.gms.security.ProviderInstaller; @@ -418,221 +416,91 @@ public Void apply(List input) { 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 key for the flag to evaluate - * @param fallback fallback value in case of errors evaluating the flag - * @return value of the flag or fallback - */ @Override public Boolean boolVariation(String flagKey, Boolean fallback) { - if (flagKey == null) { - Timber.e("Attempted to get boolean flag with a null value for key. Returning fallback: %s", fallback); - return fallback; - } - - Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); - - Boolean result = fallback; - - if (flag == null) { - Timber.e("Attempted to get non-existent boolean flag for key: %s Returning fallback: %s", flagKey, fallback); - } else { - JsonElement jsonVal = flag.getValue(); - if (jsonVal == null || jsonVal.isJsonNull()) { - Timber.e("Attempted to get boolean flag without value for key: %s Returning fallback: %s", flagKey, fallback); - } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { - result = jsonVal.getAsBoolean(); - } else { - Timber.e("Attempted to get boolean flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } - } + return variationDetailInternal(flagKey, fallback, ValueTypes.BOOLEAN, false).getValue(); + } - JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); - JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); - updateSummaryEvents(flagKey, flag, val, defaultVal); - sendFlagRequestEvent(flagKey, flag, val, defaultVal); - Timber.d("boolVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + @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 key for the flag to evaluate - * @param fallback fallback value in case of errors evaluating the flag - * @return value of the flag or fallback - */ @Override public Integer intVariation(String flagKey, Integer fallback) { - if (flagKey == null) { - Timber.e("Attempted to get integer flag with a null value for key. Returning fallback: %s", fallback); - return fallback; - } - - Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); - - Integer result = fallback; - - if (flag == null) { - Timber.e("Attempted to get non-existent integer flag for key: %s Returning fallback: %s", flagKey, fallback); - } else { - JsonElement jsonVal = flag.getValue(); - if (jsonVal == null || jsonVal.isJsonNull()) { - Timber.e("Attempted to get integer flag without value for key: %s Returning fallback: %s", flagKey, fallback); - } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { - result = jsonVal.getAsInt(); - } else { - Timber.e("Attempted to get integer flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } - } + return variationDetailInternal(flagKey, fallback, ValueTypes.INT, false).getValue(); + } - JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); - JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); - updateSummaryEvents(flagKey, flag, val, defaultVal); - sendFlagRequestEvent(flagKey, flag, val, defaultVal); - Timber.d("intVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + @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 key for the flag to evaluate - * @param fallback fallback value in case of errors evaluating the flag - * @return value of the flag or fallback - */ @Override public Float floatVariation(String flagKey, Float fallback) { - if (flagKey == null) { - Timber.e("Attempted to get float flag with a null value for key. Returning fallback: %s", fallback); - return fallback; - } - - Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); - - Float result = fallback; - - if (flag == null) { - Timber.e("Attempted to get non-existent float flag for key: %s Returning fallback: %s", flagKey, fallback); - } else { - JsonElement jsonVal = flag.getValue(); - if (jsonVal == null || jsonVal.isJsonNull()) { - Timber.e("Attempted to get float flag without value for key: %s Returning fallback: %s", flagKey, fallback); - } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { - result = jsonVal.getAsFloat(); - } else { - Timber.e("Attempted to get float flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } - } + return variationDetailInternal(flagKey, fallback, ValueTypes.FLOAT, false).getValue(); + } - JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); - JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); - updateSummaryEvents(flagKey, flag, val, defaultVal); - sendFlagRequestEvent(flagKey, flag, val, defaultVal); - Timber.d("floatVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + @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 key for the flag to evaluate - * @param fallback fallback value in case of errors evaluating the flag - * @return value of the flag or fallback - */ @Override public String stringVariation(String flagKey, String fallback) { - if (flagKey == null) { - Timber.e("Attempted to get string flag with a null value for key. Returning fallback: %s", fallback); - return fallback; - } - - Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); - - String result = fallback; - - if (flag == null) { - Timber.e("Attempted to get non-existent string flag for key: %s Returning fallback: %s", flagKey, fallback); - } else { - JsonElement jsonVal = flag.getValue(); - if (jsonVal == null || jsonVal.isJsonNull()) { - Timber.e("Attempted to get string flag without value for key: %s Returning fallback: %s", flagKey, fallback); - } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { - result = jsonVal.getAsString(); - } else { - Timber.e("Attempted to get string flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } - } + return variationDetailInternal(flagKey, fallback, ValueTypes.STRING, false).getValue(); + } - JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); - JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); - updateSummaryEvents(flagKey, flag, val, defaultVal); - sendFlagRequestEvent(flagKey, flag, val, defaultVal); - Timber.d("stringVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + @Override + public EvaluationDetail stringVariationDetail(String flagKey, String fallback) { + return variationDetailInternal(flagKey, fallback, ValueTypes.STRING, 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 key for the flag to evaluate - * @param fallback fallback value in case of errors evaluating the flag - * @return value of the flag or fallback - */ @Override public JsonElement jsonVariation(String flagKey, JsonElement fallback) { + 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 json flag with a null value for key. Returning fallback: %s", fallback); - return fallback; + 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 result = fallback; + JsonElement fallbackJson = fallback == null ? null : typeConverter.valueToJson(fallback); + JsonElement valueJson = fallbackJson; + EvaluationDetail result; if (flag == null) { - Timber.e("Attempted to get non-existent json flag for key: %s Returning fallback: %s", flagKey, fallback); + 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 { - JsonElement jsonVal = flag.getValue(); - if (jsonVal == null || jsonVal.isJsonNull()) { // TODO, return null, or fallback? can jsonVal even be null (as opposed to jsonNull)? - Timber.e("Attempted to get json flag without value for key: %s Returning fallback: %s", flagKey, fallback); + 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 { - result = jsonVal; + 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); + } } } - JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : fallback; - JsonElement val = result == null ? JsonNull.INSTANCE : result; - updateSummaryEvents(flagKey, flag, val, defaultVal); - sendFlagRequestEvent(flagKey, flag, val, defaultVal); - 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; } @@ -801,7 +669,7 @@ void startForegroundUpdating() { } } - private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, JsonElement fallback) { + private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, JsonElement fallback, EvaluationReason reason) { if (flag == null) return; @@ -809,16 +677,16 @@ private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, 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 = 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)); } } } 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..839dc893 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 @@ -26,16 +26,140 @@ public interface LDClientInterface extends Closeable { 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + EvaluationDetail jsonVariationDetail(String flagKey, JsonElement fallback); + void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener); void unregisterFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener); 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..7ba922c2 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ValueTypes.java @@ -0,0 +1,92 @@ +package com.launchdarkly.android; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + +/** + * 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); + } + }; + + public static final Converter JSON = new Converter() { + @Override + public JsonElement valueFromJson(JsonElement jsonValue) { + return jsonValue; + } + + @Override + public JsonElement valueToJson(JsonElement value) { + return value; + } + }; +} From bd6ad95828676ec99949d61b0d0f32cbabf4cb00 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 15 Feb 2019 19:59:34 +0000 Subject: [PATCH 055/152] Abstract FlagStoreManager from FlagStore, new FlagStoreFactory class so manager can construct FlagStores of unknown type. Reformatted interfaces. Removed unused imports. --- .../android/EvaluationReason.java | 36 ++++--- .../com/launchdarkly/android/LDClient.java | 32 +++--- .../android/flagstore/FlagInterface.java | 5 + .../android/flagstore/FlagStore.java | 10 ++ .../flagstore/FlagStoreFactoryInterface.java | 9 ++ .../android/flagstore/FlagStoreManager.java | 4 + .../android/flagstore/FlagUpdate.java | 1 + .../sharedprefs/SharedPrefsFlagStore.java | 26 ++++- .../SharedPrefsFlagStoreFactory.java | 21 ++++ .../SharedPrefsFlagStoreManager.java | 97 ++++++------------- .../response/BaseUserSharedPreferences.java | 11 --- 11 files changed, 139 insertions(+), 113 deletions(-) create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactoryInterface.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactory.java 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 index 1b82cef2..61626018 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java @@ -7,7 +7,7 @@ /** * Describes the reason that a flag evaluation produced a particular value. This is returned by * methods such as {@code boolVariationDetail()}. - * + *

    * Note that this is an enum-like class hierarchy rather than an enum, because some of the * possible reasons have their own properties. * @@ -93,10 +93,10 @@ public static enum ErrorKind { /** * Returns an enum indicating the general category of the reason. + * * @return a {@link Kind} value */ - public Kind getKind() - { + public Kind getKind() { return kind; } @@ -105,13 +105,13 @@ public String toString() { return getKind().name(); } - protected EvaluationReason(Kind kind) - { + protected EvaluationReason(Kind kind) { this.kind = kind; } /** * Returns an instance of {@link Off}. + * * @return a reason object */ public static Off off() { @@ -120,6 +120,7 @@ public static Off off() { /** * Returns an instance of {@link TargetMatch}. + * * @return a reason object */ public static TargetMatch targetMatch() { @@ -128,8 +129,9 @@ public static TargetMatch targetMatch() { /** * Returns an instance of {@link RuleMatch}. + * * @param ruleIndex the rule index - * @param ruleId the rule identifier + * @param ruleId the rule identifier * @return a reason object */ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { @@ -138,6 +140,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { /** * Returns an instance of {@link PrerequisiteFailed}. + * * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ @@ -147,6 +150,7 @@ public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { /** * Returns an instance of {@link Fallthrough}. + * * @return a reason object */ public static Fallthrough fallthrough() { @@ -155,6 +159,7 @@ public static Fallthrough fallthrough() { /** * Returns an instance of {@link Error}. + * * @param errorKind describes the type of error * @return a reason object */ @@ -164,9 +169,12 @@ public static Error error(ErrorKind errorKind) { /** * Returns an instance of {@link Unknown}. + * * @return a reason object */ - public static Unknown unknown() { return Unknown.instance; } + public static Unknown unknown() { + return Unknown.instance; + } /** * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned @@ -185,8 +193,7 @@ private Off() { * for this flag. */ public static class TargetMatch extends EvaluationReason { - private TargetMatch() - { + private TargetMatch() { super(Kind.TARGET_MATCH); } @@ -217,7 +224,7 @@ public String getRuleId() { @Override public boolean equals(Object other) { if (other instanceof RuleMatch) { - RuleMatch o = (RuleMatch)other; + RuleMatch o = (RuleMatch) other; return ruleIndex == o.ruleIndex && objectsEqual(ruleId, o.ruleId); } return false; @@ -253,7 +260,7 @@ public String getPrerequisiteKey() { @Override public boolean equals(Object other) { return (other instanceof PrerequisiteFailed) && - ((PrerequisiteFailed)other).prerequisiteKey.equals(prerequisiteKey); + ((PrerequisiteFailed) other).prerequisiteKey.equals(prerequisiteKey); } @Override @@ -272,8 +279,7 @@ public String toString() { * match any targets or rules. */ public static class Fallthrough extends EvaluationReason { - private Fallthrough() - { + private Fallthrough() { super(Kind.FALLTHROUGH); } @@ -317,7 +323,9 @@ public String toString() { * not supported by this version of the SDK. */ public static class Unknown extends EvaluationReason { - private Unknown() { super(Kind.UNKNOWN); } + private Unknown() { + super(Kind.UNKNOWN); + } private static final Unknown instance = new Unknown(); } 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 04d1f610..28b2e2e8 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 @@ -11,6 +11,7 @@ import com.google.android.gms.common.GooglePlayServicesNotAvailableException; import com.google.android.gms.common.GooglePlayServicesRepairableException; +import com.google.android.gms.security.ProviderInstaller; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.base.Preconditions; @@ -18,16 +19,13 @@ 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.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.android.flagstore.Flag; -import com.launchdarkly.android.flagstore.FlagInterface; import com.launchdarkly.android.response.GsonCache; import com.launchdarkly.android.response.SummaryEventSharedPreferences; -import com.google.android.gms.security.ProviderInstaller; import java.io.Closeable; import java.io.IOException; @@ -202,11 +200,11 @@ private static boolean validateParameter(T parameter) { * 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); @@ -426,7 +424,7 @@ public Void apply(List input) { *

  • Any other error
  • * * - * @param flagKey key for the flag to evaluate + * @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 */ @@ -470,7 +468,7 @@ public Boolean boolVariation(String flagKey, Boolean fallback) { *
  • Any other error
  • * * - * @param flagKey key for the flag to evaluate + * @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 */ @@ -514,7 +512,7 @@ public Integer intVariation(String flagKey, Integer fallback) { *
  • Any other error
  • * * - * @param flagKey key for the flag to evaluate + * @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 */ @@ -558,7 +556,7 @@ public Float floatVariation(String flagKey, Float fallback) { *
  • Any other error
  • * * - * @param flagKey key for the flag to evaluate + * @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 */ @@ -602,7 +600,7 @@ public String stringVariation(String flagKey, String fallback) { *
  • Any other error
  • * * - * @param flagKey key for the flag to evaluate + * @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 */ @@ -763,8 +761,8 @@ 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 + * @param flagKey the flag key to attach the listener to + * @param listener the listener to attach to the flag key */ @Override public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { @@ -774,8 +772,8 @@ public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListene /** * Unregisters a {@link FeatureFlagChangeListener} for the flagKey * - * @param flagKey - * @param listener + * @param flagKey the flag key to attach the listener to + * @param listener the listener to attach to the flag key */ @Override public void unregisterFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { 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 index 47cb6aff..b7e95aef 100644 --- 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 @@ -9,9 +9,14 @@ public interface FlagInterface { @NonNull String getKey(); + JsonElement getValue(); + Integer getVersion(); + Integer getFlagVersion(); + Integer getVariation(); + 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 index ba2e2423..3098c936 100644 --- 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 @@ -6,14 +6,24 @@ public interface FlagStore { + void delete(); + void clear(); + boolean containsKey(String key); + @Nullable Flag getFlag(String flagKey); + void applyFlagUpdate(FlagUpdate flagUpdate); + void applyFlagUpdates(List flagUpdates); + void clearAndApplyFlagUpdates(List flagUpdates); + List getAllFlags(); + void registerOnStoreUpdatedListener(StoreUpdatedListener storeUpdatedListener); + void unregisterOnStoreUpdatedListener(); } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactoryInterface.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactoryInterface.java new file mode 100644 index 00000000..61e54017 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactoryInterface.java @@ -0,0 +1,9 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +public interface FlagStoreFactoryInterface { + + 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 index 83b11df8..745588a2 100644 --- 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 @@ -7,8 +7,12 @@ public interface FlagStoreManager { void switchToUser(String userKey); + FlagStore getCurrentUserStore(); + void registerListener(String key, FeatureFlagChangeListener listener); + void unRegisterListener(String key, FeatureFlagChangeListener listener); + Collection getListenersByKey(String key); } 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 index e68a871b..c218cc81 100644 --- 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 @@ -3,6 +3,7 @@ public interface FlagUpdate { Flag updateFlag(Flag before); + String flagToUpdate(); } 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 index dab96655..daa5a155 100644 --- 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 @@ -1,11 +1,12 @@ 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.google.gson.Gson; import com.launchdarkly.android.flagstore.Flag; import com.launchdarkly.android.flagstore.FlagStore; import com.launchdarkly.android.flagstore.FlagStoreUpdateType; @@ -13,6 +14,7 @@ import com.launchdarkly.android.flagstore.StoreUpdatedListener; import com.launchdarkly.android.response.GsonCache; +import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -23,11 +25,29 @@ public class SharedPrefsFlagStore implements FlagStore { + private static final String SHARED_PREFS_BASE_KEY = "LaunchDarkly-"; + private final String prefsKey; + private Application application; private SharedPreferences sharedPreferences; private StoreUpdatedListener storeUpdatedListener; - public SharedPrefsFlagStore(Application application, String name) { - this.sharedPreferences = application.getSharedPreferences(name, Context.MODE_PRIVATE); + public 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); + } + + @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 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..a7947e8b --- /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.FlagStoreFactoryInterface; + +public class SharedPrefsFlagStoreFactory implements FlagStoreFactoryInterface { + + 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 index 78773df8..0f5bb459 100644 --- 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 @@ -1,31 +1,27 @@ package com.launchdarkly.android.flagstore.sharedprefs; -import android.annotation.SuppressLint; 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.FlagStoreFactoryInterface; import com.launchdarkly.android.flagstore.FlagStoreManager; import com.launchdarkly.android.flagstore.FlagStoreUpdateType; import com.launchdarkly.android.flagstore.StoreUpdatedListener; -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 java.util.TreeMap; import timber.log.Timber; @@ -34,17 +30,18 @@ public class SharedPrefsFlagStoreManager implements FlagStoreManager, StoreUpdat private static final String SHARED_PREFS_BASE_KEY = "LaunchDarkly-"; private static final int MAX_USERS = 5; - private final Application application; + @NonNull + private final FlagStoreFactoryInterface flagStoreFactory; + @NonNull private String mobileKey; - private final SharedPreferences usersSharedPrefs; private FlagStore currentFlagStore; - + private final SharedPreferences usersSharedPrefs; private final Multimap listeners; - public SharedPrefsFlagStoreManager(Application application, String mobileKey) { - this.application = application; + public SharedPrefsFlagStoreManager(@NonNull Application application, @NonNull String mobileKey, @NonNull FlagStoreFactoryInterface 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); @@ -55,50 +52,29 @@ public void switchToUser(String userKey) { if (currentFlagStore != null) { currentFlagStore.unregisterOnStoreUpdatedListener(); } - currentFlagStore = new SharedPrefsFlagStore(application, sharedPrefsKeyForUser(userKey)); + currentFlagStore = flagStoreFactory.createFlagStore(storeIdentifierForUser(userKey)); currentFlagStore.registerOnStoreUpdatedListener(this); usersSharedPrefs.edit() .putLong(userKey, 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(); + int usersStored = usersSharedPrefs.getAll().size(); + if (usersStored > MAX_USERS) { + Iterator oldestFirstUsers = getAllUsers().iterator(); + while (usersStored-- > MAX_USERS) { + String removed = oldestFirstUsers.next(); + Timber.d("Exceeded max # of users: [%s] Removing user: [%s]", MAX_USERS, removed); + flagStoreFactory.createFlagStore(storeIdentifierForUser(removed)).delete(); + usersSharedPrefs.edit() + .remove(removed) + .apply(); + } } } - /** - * Completely deletes a user's saved flag settings and the remaining empty SharedPreferences xml file. - * - * @param userKey key the user's flag settings are stored under - */ - @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 loadSharedPrefsForUser(String userKey) { - Timber.d("Using SharedPreferences key: [%s]", sharedPrefsKeyForUser(userKey)); - return application.getSharedPreferences(sharedPrefsKeyForUser(userKey), Context.MODE_PRIVATE); - } - - private String sharedPrefsKeyForUser(String userKey) { - return SHARED_PREFS_BASE_KEY + mobileKey + userKey + "-flags"; + private String storeIdentifierForUser(String userKey) { + return mobileKey + userKey; } @Override @@ -129,27 +105,19 @@ public void unRegisterListener(String key, FeatureFlagChangeListener listener) { } // Gets all users sorted by creation time (oldest first) - private List getAllUsers() { + private Collection getAllUsers() { Map all = usersSharedPrefs.getAll(); - Map allTyped = new HashMap<>(); - //get typed versions of the users' timestamps: + TreeMap sortedMap = new TreeMap<>(); + //get typed versions of the users' timestamps and insert into sorted TreeMap 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))); + 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"); } } - - 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; + return sortedMap.values(); } private static String userAndTimeStampToHumanReadableString(String userSharedPrefsKey, Long timestamp) { @@ -178,13 +146,6 @@ public void run() { } } - class EntryComparator implements Comparator> { - @Override - public int compare(Map.Entry lhs, Map.Entry rhs) { - return (int) (lhs.getValue() - rhs.getValue()); - } - } - public Collection getListenersByKey(String key) { synchronized (listeners) { return listeners.get(key); 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 index 5130e27f..0c45d8bc 100644 --- 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 @@ -22,17 +22,6 @@ public void 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 public JsonObject getValueAsJsonObject(String flagResponseKey) { From 8460f0c70d8511693ea91d5259e06fdb2f2526e4 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 15 Feb 2019 20:03:05 +0000 Subject: [PATCH 056/152] Handle null case in allFlags, actually commit changes to UserManager. --- .../src/main/java/com/launchdarkly/android/LDClient.java | 4 +++- .../src/main/java/com/launchdarkly/android/UserManager.java | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 28b2e2e8..7928384a 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 @@ -401,7 +401,9 @@ public Void apply(List input) { List flags = userManager.getCurrentUserFlagStore().getAllFlags(); for (Flag flag : flags) { JsonElement jsonVal = flag.getValue(); - if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { + if (jsonVal == null) { + result.put(flag.getKey(), null); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { result.put(flag.getKey(), jsonVal.getAsBoolean()); } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { // TODO distinguish ints? 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 bd4277e2..87875260 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 @@ -17,6 +17,7 @@ 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.response.DeleteFlagResponse; import com.launchdarkly.android.response.GsonCache; @@ -57,7 +58,7 @@ static synchronized UserManager newInstance(Application application, FeatureFlag UserManager(Application application, FeatureFlagFetcher fetcher, String environmentName, String mobileKey) { this.application = application; this.fetcher = fetcher; - this.flagStoreManager = new SharedPrefsFlagStoreManager(application, mobileKey); + this.flagStoreManager = new SharedPrefsFlagStoreManager(application, mobileKey, new SharedPrefsFlagStoreFactory(application)); this.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-summaryevents"); this.environmentName = environmentName; @@ -150,7 +151,7 @@ private void saveFlagSettings(JsonObject flagsJson) { } flagStoreManager.getCurrentUserStore().clearAndApplyFlagUpdates(flags); } catch (Exception e) { - + Timber.d("Invalid JsonObject for flagSettings: %s", flagsJson); } } From f565301c675cf9a5e8fee3add60c70f701980068 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 15 Feb 2019 21:39:47 +0000 Subject: [PATCH 057/152] Hopefully fix edge cases in summary event reporting to pass testing. --- .../com/launchdarkly/android/LDClient.java | 5 +--- .../UserSummaryEventSharedPreferences.java | 25 ++++++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) 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 7928384a..42a1e518 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 @@ -853,13 +853,10 @@ private void sendEvent(Event event) { */ private void updateSummaryEvents(String flagKey, Flag flag, JsonElement result, JsonElement fallback) { if (flag == null) { - userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, -1, -1, true); + userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, -1, null, true); } else { int version = flag.getVersionForEvents(); Integer variation = flag.getVariation(); - if (variation == null) - variation = -1; - userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, version, variation, false); } } 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 index c1b28580..04e864ec 100644 --- 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 @@ -7,6 +7,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import com.google.gson.JsonNull; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -22,7 +23,7 @@ public UserSummaryEventSharedPreferences(Application application, String name) { @Override public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer nullableVariation, boolean isUnknown) { - int variation = nullableVariation == null ? -1 : nullableVariation; + JsonElement variation = nullableVariation == null ? JsonNull.INSTANCE : new JsonPrimitive(nullableVariation); JsonObject object = getValueAsJsonObject(flagResponseKey); if (object == null) { object = createNewEvent(value, defaultVal, version, variation, isUnknown); @@ -33,12 +34,24 @@ public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElem for (JsonElement element : countersArray) { if (element instanceof JsonObject) { JsonObject asJsonObject = element.getAsJsonObject(); + boolean unknownElement = asJsonObject.get("unknown") != null && 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.getAsInt() == variation; - if ((isSameVersion && isSameVariation) || (variationElement == null && versionElement == null && isUnknown && value.equals(asJsonObject.get("value")))) { + boolean isSameVariation = variationElement != null && variationElement.equals(variation); + if (isSameVersion && isSameVariation) { variationExists = true; int currentCount = asJsonObject.get("count").getAsInt(); asJsonObject.add("count", new JsonPrimitive(++currentCount)); @@ -61,7 +74,7 @@ public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElem editor.apply(); } - private JsonObject createNewEvent(JsonElement value, JsonElement defaultVal, int version, int variation, boolean isUnknown) { + private JsonObject createNewEvent(JsonElement value, JsonElement defaultVal, int version, JsonElement variation, boolean isUnknown) { JsonObject object = new JsonObject(); object.add("default", defaultVal); JsonArray countersArray = new JsonArray(); @@ -70,7 +83,7 @@ private JsonObject createNewEvent(JsonElement value, JsonElement defaultVal, int return object; } - private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElement value, int version, int variation, boolean isUnknown) { + private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElement value, int version, JsonElement variation, boolean isUnknown) { JsonObject newCounter = new JsonObject(); if (isUnknown) { newCounter.add("unknown", new JsonPrimitive(true)); @@ -78,7 +91,7 @@ private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElemen } else { newCounter.add("value", value); newCounter.add("version", new JsonPrimitive(version)); - newCounter.add("variation", new JsonPrimitive(variation)); + newCounter.add("variation", variation); } newCounter.add("count", new JsonPrimitive(1)); countersArray.add(newCounter); From 0b3d570e7d4e86addd512eaebc338a211f995d4c Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Sat, 16 Feb 2019 00:12:34 +0000 Subject: [PATCH 058/152] Hopefully fix edge cases in summary event reporting to pass testing. --- .../android/response/UserSummaryEventSharedPreferences.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 04e864ec..23b39d93 100644 --- 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 @@ -34,7 +34,8 @@ public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElem for (JsonElement element : countersArray) { if (element instanceof JsonObject) { JsonObject asJsonObject = element.getAsJsonObject(); - boolean unknownElement = asJsonObject.get("unknown") != null && asJsonObject.get("unknown").getAsBoolean(); + boolean unknownElement = asJsonObject.get("unknown") != null && !asJsonObject.get("unknown").equals(JsonNull.INSTANCE) && asJsonObject.get("unknown").getAsBoolean(); + if (unknownElement != isUnknown) { continue; } From 709e1078e755c1321f232fb2439de9cbd6041c5a Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Sat, 16 Feb 2019 00:47:06 +0000 Subject: [PATCH 059/152] Simplify getFeaturesJsonObject as no longer using -1 as placeholder for null for variations. --- .../response/UserSummaryEventSharedPreferences.java | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) 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 index 23b39d93..beffd0ef 100644 --- 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 @@ -102,18 +102,7 @@ private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElemen 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); - } + returnObject.add(key, getValueAsJsonObject(key)); } return returnObject; } From 18044378a459629773b0fb65fabd8bec13f16ea0 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 19 Feb 2019 04:55:54 +0000 Subject: [PATCH 060/152] Make Flag non-mutable. Move GsonCache to gson package, move custom serializer/deserializers to classes in gson package and create one for PUT responses. Removed BaseUserSharedPreferences. --- .../response/UserFlagResponseTest.java | 1 + .../com/launchdarkly/android/LDClient.java | 2 +- .../com/launchdarkly/android/UserManager.java | 25 +--- .../launchdarkly/android/flagstore/Flag.java | 32 ----- .../sharedprefs/SharedPrefsFlagStore.java | 2 +- .../gson/EvaluationReasonSerialization.java | 96 +++++++++++++++ .../gson/FlagsResponseSerialization.java | 38 ++++++ .../launchdarkly/android/gson/GsonCache.java | 25 ++++ .../response/BaseUserSharedPreferences.java | 49 -------- .../android/response/FlagsResponse.java | 21 ++++ .../android/response/GsonCache.java | 114 ------------------ .../UserSummaryEventSharedPreferences.java | 38 +++++- 12 files changed, 225 insertions(+), 218 deletions(-) create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/FlagsResponseSerialization.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/GsonCache.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/BaseUserSharedPreferences.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/GsonCache.java diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java index 296fc6d1..14948686 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java @@ -7,6 +7,7 @@ import com.google.gson.JsonPrimitive; import com.launchdarkly.android.EvaluationReason; import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.gson.GsonCache; import org.junit.Test; 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 42a1e518..0a23730b 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 @@ -24,7 +24,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.android.flagstore.Flag; -import com.launchdarkly.android.response.GsonCache; +import com.launchdarkly.android.gson.GsonCache; import com.launchdarkly.android.response.SummaryEventSharedPreferences; import java.io.Closeable; 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 87875260..fc92be9c 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 @@ -13,20 +13,19 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; 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.GsonCache; +import com.launchdarkly.android.response.FlagsResponse; import com.launchdarkly.android.response.SummaryEventSharedPreferences; import com.launchdarkly.android.response.UserSummaryEventSharedPreferences; -import java.util.ArrayList; import java.util.Collection; -import java.util.Map; +import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -141,14 +140,7 @@ private void saveFlagSettings(JsonObject flagsJson) { Timber.d("saveFlagSettings for user key: %s", currentUser.getKey()); try { - Map flagMap; - final ArrayList flags = new ArrayList<>(); - flagMap = GsonCache.getGson().fromJson(flagsJson, new TypeToken>() {}.getType()); - for (Map.Entry flagEntry : flagMap.entrySet()) { - Flag flag = flagEntry.getValue(); - flag.setKey(flagEntry.getKey()); - flags.add(flag); - } + 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); @@ -188,14 +180,7 @@ public Void call() { ListenableFuture putCurrentUserFlags(final String json) { try { - Map flagMap; - final ArrayList flags = new ArrayList<>(); - flagMap = GsonCache.getGson().fromJson(json, new TypeToken>() {}.getType()); - for (Map.Entry flagEntry : flagMap.entrySet()) { - Flag flag = flagEntry.getValue(); - flag.setKey(flagEntry.getKey()); - flags.add(flag); - } + final List flags = GsonCache.getGson().fromJson(json, FlagsResponse.class).getFlags(); ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); return service.submit(new Callable() { @Override 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 index 3fc286c2..4f664ac1 100644 --- 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 @@ -33,67 +33,35 @@ public String getKey() { return key; } - public void setKey(@NonNull String key) { - this.key = key; - } - public JsonElement getValue() { return value; } - public void setValue(JsonElement value) { - this.value = value; - } - public Integer getVersion() { return version; } - public void setVersion(Integer version) { - this.version = version; - } - public Integer getFlagVersion() { return flagVersion; } - public void setFlagVersion(Integer flagVersion) { - this.flagVersion = flagVersion; - } - public Integer getVariation() { return variation; } - public void setVariation(Integer variation) { - this.variation = variation; - } - public boolean getTrackEvents() { return trackEvents == null ? false : trackEvents; } - public void setTrackEvents(Boolean trackEvents) { - this.trackEvents = trackEvents; - } - public Long getDebugEventsUntilDate() { return debugEventsUntilDate; } - public void setDebugEventsUntilDate(Long debugEventsUntilDate) { - this.debugEventsUntilDate = debugEventsUntilDate; - } - @Override public EvaluationReason getReason() { return reason; } - public void setReason(EvaluationReason reason) { - this.reason = reason; - } - public boolean isVersionMissing() { return version == null; } 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 index daa5a155..1d14566d 100644 --- 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 @@ -12,7 +12,7 @@ import com.launchdarkly.android.flagstore.FlagStoreUpdateType; import com.launchdarkly.android.flagstore.FlagUpdate; import com.launchdarkly.android.flagstore.StoreUpdatedListener; -import com.launchdarkly.android.response.GsonCache; +import com.launchdarkly.android.gson.GsonCache; import java.io.File; import java.util.ArrayList; 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..13c27f79 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java @@ -0,0 +1,96 @@ +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) { + if (src instanceof EvaluationReason.Off) { + return context.serialize(src, EvaluationReason.Off.class); + } else if (src instanceof EvaluationReason.Fallthrough) { + return context.serialize(src, EvaluationReason.Fallthrough.class); + } else if (src instanceof EvaluationReason.TargetMatch) { + return context.serialize(src, EvaluationReason.TargetMatch.class); + } else if (src instanceof EvaluationReason.RuleMatch) { + return context.serialize(src, EvaluationReason.RuleMatch.class); + } else if (src instanceof EvaluationReason.PrerequisiteFailed) { + return context.serialize(src, EvaluationReason.PrerequisiteFailed.class); + } else if (src instanceof EvaluationReason.Error) { + return context.serialize(src, EvaluationReason.Error.class); + } else if (src instanceof EvaluationReason.Unknown) { + return context.serialize(src, EvaluationReason.Unknown.class); + } + return null; + } + + @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..4a2c12a9 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/GsonCache.java @@ -0,0 +1,25 @@ +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 Gson gson; + + public static Gson getGson() { + if (gson == null) { + gson = createGson(); + } + 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 0c45d8bc..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/BaseUserSharedPreferences.java +++ /dev/null @@ -1,49 +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(); - } - - @SuppressLint("ApplySharedPref") - @Nullable - public 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/FlagsResponse.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java new file mode 100644 index 00000000..6e26de99 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java @@ -0,0 +1,21 @@ +package com.launchdarkly.android.response; + +import android.support.annotation.NonNull; + +import com.launchdarkly.android.flagstore.Flag; + +import java.util.List; + +public class FlagsResponse { + @NonNull + private List flags; + + public FlagsResponse(@NonNull List flags) { + this.flags = flags; + } + + @NonNull + public List getFlags() { + return flags; + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/GsonCache.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/GsonCache.java deleted file mode 100644 index b90db6a3..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/GsonCache.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.launchdarkly.android.response; - -import android.support.annotation.Nullable; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -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; - -public class GsonCache { - - private static Gson gson; - - public static Gson getGson() { - if (gson == null) { - gson = createGson(); - } - return gson; - } - - @Nullable - private static > T parseEnum(Class c, String name, T fallback) { - try { - return Enum.valueOf(c, name); - } catch (IllegalArgumentException e) { - return fallback; - } - } - - private static Gson createGson() { - GsonBuilder gsonBuilder = new GsonBuilder(); - JsonSerializer serializer = new JsonSerializer() { - @Override - public JsonElement serialize(EvaluationReason src, Type typeOfSrc, JsonSerializationContext context) { - if (src instanceof EvaluationReason.Off) { - return context.serialize(src, EvaluationReason.Off.class); - } else if (src instanceof EvaluationReason.Fallthrough) { - return context.serialize(src, EvaluationReason.Fallthrough.class); - } else if (src instanceof EvaluationReason.TargetMatch) { - return context.serialize(src, EvaluationReason.TargetMatch.class); - } else if (src instanceof EvaluationReason.RuleMatch) { - return context.serialize(src, EvaluationReason.RuleMatch.class); - } else if (src instanceof EvaluationReason.PrerequisiteFailed) { - return context.serialize(src, EvaluationReason.PrerequisiteFailed.class); - } else if (src instanceof EvaluationReason.Error) { - return context.serialize(src, EvaluationReason.Error.class); - } else if (src instanceof EvaluationReason.Unknown) { - return context.serialize(src, EvaluationReason.Unknown.class); - } - return null; - } - }; - JsonDeserializer deserializer = new JsonDeserializer() { - @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; - } - }; - gsonBuilder.registerTypeAdapter(EvaluationReason.class, serializer); - gsonBuilder.registerTypeAdapter(EvaluationReason.class, deserializer); - return gsonBuilder.create(); - } -} 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 index beffd0ef..1744c4b5 100644 --- 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 @@ -1,5 +1,6 @@ package com.launchdarkly.android.response; +import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; import android.content.SharedPreferences; @@ -9,13 +10,16 @@ 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; /** * Created by jamesthacker on 4/12/18. */ -public class UserSummaryEventSharedPreferences extends BaseUserSharedPreferences implements SummaryEventSharedPreferences { +public class UserSummaryEventSharedPreferences implements SummaryEventSharedPreferences { + + private SharedPreferences sharedPreferences; public UserSummaryEventSharedPreferences(Application application, String name) { this.sharedPreferences = application.getSharedPreferences(name, Context.MODE_PRIVATE); @@ -106,4 +110,36 @@ public JsonObject getFeaturesJsonObject() { } 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 void clear() { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.clear(); + editor.apply(); + } + } From 261d90c3a61eb321626410c22ffd408ef8a19ffd Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 21 Feb 2019 04:31:09 +0000 Subject: [PATCH 061/152] Send summary event even if stored flag doesn't exist. --- .../src/main/java/com/launchdarkly/android/LDClient.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0a23730b..141f79aa 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 @@ -802,8 +802,10 @@ void startForegroundUpdating() { } private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, JsonElement fallback) { - if (flag == null) + if (flag == null) { + sendSummaryEvent(); return; + } int version = flag.getVersionForEvents(); Integer variation = flag.getVariation(); From f4fb4676f579f0bfb8cbdb7caed1307ec4185bd3 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 21 Feb 2019 18:43:42 +0000 Subject: [PATCH 062/152] Move sendSummaryEvent update code to UserSummaryEventSharedPreferences to synchronize to prevent data race on sending, updating, and clearing event store. Move SummaryEventSharedPreferences and UserSummaryEventSharedPreferences out of response package. --- ...UserSummaryEventSharedPreferencesTest.java | 36 ++++--------- .../launchdarkly/android/EventProcessor.java | 10 +--- .../com/launchdarkly/android/LDClient.java | 27 ---------- .../SummaryEventSharedPreferences.java | 8 ++- .../com/launchdarkly/android/UserManager.java | 2 - .../UserSummaryEventSharedPreferences.java | 51 ++++++++++++++++--- 6 files changed, 61 insertions(+), 73 deletions(-) rename launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/{response => }/UserSummaryEventSharedPreferencesTest.java (78%) rename launchdarkly-android-client/src/main/java/com/launchdarkly/android/{response => }/SummaryEventSharedPreferences.java (74%) rename launchdarkly-android-client/src/main/java/com/launchdarkly/android/{response => }/UserSummaryEventSharedPreferences.java (76%) 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/main/java/com/launchdarkly/android/EventProcessor.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EventProcessor.java index 62e08398..272fee0b 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,10 +90,6 @@ boolean sendEvent(Event e) { return queue.offer(e); } - void setSummaryEvent(SummaryEvent summaryEvent) { - this.summaryEvent = summaryEvent; - } - @Override public void close() throws IOException { stop(); @@ -126,10 +120,10 @@ public 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/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index 141f79aa..daca727e 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 @@ -21,11 +21,9 @@ 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.JsonPrimitive; import com.launchdarkly.android.flagstore.Flag; import com.launchdarkly.android.gson.GsonCache; -import com.launchdarkly.android.response.SummaryEventSharedPreferences; import java.io.Closeable; import java.io.IOException; @@ -803,7 +801,6 @@ void startForegroundUpdating() { private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, JsonElement fallback) { if (flag == null) { - sendSummaryEvent(); return; } @@ -824,8 +821,6 @@ private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, } } } - - sendSummaryEvent(); } void startBackgroundPolling() { @@ -863,28 +858,6 @@ private void updateSummaryEvents(String flagKey, Flag flag, JsonElement result, } } - /** - * 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; - } - } - SummaryEvent summaryEvent = new SummaryEvent(startDate, System.currentTimeMillis(), features); - Timber.d("Sending Summary Event: %s", summaryEvent.toString()); - eventProcessor.setSummaryEvent(summaryEvent); - } - @VisibleForTesting public void clearSummaryEventSharedPreferences() { userManager.getSummaryEventSharedPreferences().clear(); 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/SummaryEventSharedPreferences.java similarity index 74% rename from launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/SummaryEventSharedPreferences.java rename to launchdarkly-android-client/src/main/java/com/launchdarkly/android/SummaryEventSharedPreferences.java index b5b710fd..6cb78ed0 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/SummaryEventSharedPreferences.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/SummaryEventSharedPreferences.java @@ -1,9 +1,8 @@ -package com.launchdarkly.android.response; +package com.launchdarkly.android; import android.support.annotation.Nullable; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; /** * Created by jamesthacker on 4/12/18. @@ -12,8 +11,7 @@ public interface SummaryEventSharedPreferences { void clear(); - void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer variation, boolean unknown); - - JsonObject getFeaturesJsonObject(); + SummaryEvent getSummaryEvent(); + SummaryEvent getSummaryEventAndClear(); } 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 fc92be9c..93b3601e 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 @@ -21,8 +21,6 @@ import com.launchdarkly.android.gson.GsonCache; import com.launchdarkly.android.response.DeleteFlagResponse; import com.launchdarkly.android.response.FlagsResponse; -import com.launchdarkly.android.response.SummaryEventSharedPreferences; -import com.launchdarkly.android.response.UserSummaryEventSharedPreferences; import java.util.Collection; import java.util.List; 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/UserSummaryEventSharedPreferences.java similarity index 76% rename from launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java rename to launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserSummaryEventSharedPreferences.java index 1744c4b5..97531850 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserSummaryEventSharedPreferences.java @@ -1,4 +1,4 @@ -package com.launchdarkly.android.response; +package com.launchdarkly.android; import android.annotation.SuppressLint; import android.app.Application; @@ -13,6 +13,8 @@ import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; +import timber.log.Timber; + /** * Created by jamesthacker on 4/12/18. */ @@ -21,12 +23,12 @@ public class UserSummaryEventSharedPreferences implements SummaryEventSharedPref private SharedPreferences sharedPreferences; - public UserSummaryEventSharedPreferences(Application application, String name) { + 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, @Nullable Integer nullableVariation, boolean isUnknown) { + public synchronized void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer nullableVariation, boolean isUnknown) { JsonElement variation = nullableVariation == null ? JsonNull.INSTANCE : new JsonPrimitive(nullableVariation); JsonObject object = getValueAsJsonObject(flagResponseKey); if (object == null) { @@ -74,9 +76,46 @@ public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElem 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, boolean isUnknown) { @@ -102,8 +141,8 @@ private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElemen countersArray.add(newCounter); } - @Override - public JsonObject getFeaturesJsonObject() { + + private JsonObject getFeaturesJsonObject() { JsonObject returnObject = new JsonObject(); for (String key : sharedPreferences.getAll().keySet()) { returnObject.add(key, getValueAsJsonObject(key)); @@ -136,7 +175,7 @@ private JsonObject getValueAsJsonObject(String flagResponseKey) { return null; } - public void clear() { + public synchronized void clear() { SharedPreferences.Editor editor = sharedPreferences.edit(); editor.clear(); editor.apply(); From 748989067df5e92d95900efcbb405a5ff9700a0e Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 22 Feb 2019 00:21:40 +0000 Subject: [PATCH 063/152] Update SharedPrefsFlagStore to hold StoreUpdatedListener in weak reference. Fix various warnings. --- .../FlagTest.java} | 4 +- .../sharedprefs/SharedPrefsFlagStoreTest.java | 2 +- .../launchdarkly/android/EventProcessor.java | 4 +- .../com/launchdarkly/android/Foreground.java | 2 +- .../android/HttpFeatureFlagFetcher.java | 8 ++-- .../com/launchdarkly/android/LDClient.java | 11 +---- .../java/com/launchdarkly/android/LDUser.java | 1 - .../android/PollingUpdateProcessor.java | 1 - .../android/StreamUpdateProcessor.java | 4 +- .../com/launchdarkly/android/Throttler.java | 4 +- .../launchdarkly/android/UpdateProcessor.java | 2 +- .../com/launchdarkly/android/UserHasher.java | 2 - .../com/launchdarkly/android/UserManager.java | 3 +- .../java/com/launchdarkly/android/Util.java | 44 +++++-------------- .../sharedprefs/SharedPrefsFlagStore.java | 17 ++++--- 15 files changed, 39 insertions(+), 70 deletions(-) rename launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/{response/UserFlagResponseTest.java => flagstore/FlagTest.java} (98%) diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java similarity index 98% rename from launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java rename to launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java index 14948686..13d01004 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.android.response; +package com.launchdarkly.android.flagstore; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -19,7 +19,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -public class UserFlagResponseTest { +public class FlagTest { private static final Gson gson = GsonCache.getGson(); private static final Map TEST_REASONS = ImmutableMap.builder() 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 index 47abae89..e8d8d7bf 100644 --- 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 @@ -183,7 +183,7 @@ public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() 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 - // UserFlagResponseTest. + // FlagTest. final EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); final Flag flag1 = new Flag("key1", new JsonPrimitive(true), 11, 1, 1, null, null, reason); 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 272fee0b..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 @@ -91,7 +91,7 @@ boolean sendEvent(Event e) { } @Override - public void close() throws IOException { + public void close() { stop(); flush(); } @@ -116,7 +116,7 @@ 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); 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 ebe8d5be..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(); @@ -139,10 +139,9 @@ private Request getDefaultRequest(LDUser user) { 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) { @@ -153,11 +152,10 @@ private Request getReportRequest(LDUser user) { 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 daca727e..b79e8e86 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 @@ -644,7 +644,7 @@ 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) { @@ -653,16 +653,9 @@ private void closeInternal() throws IOException { } 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; } /** 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/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 6dcccb30..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)); 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/UserManager.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserManager.java index 93b3601e..3cec804a 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 @@ -78,9 +78,8 @@ 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)); 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/flagstore/sharedprefs/SharedPrefsFlagStore.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java index 1d14566d..e36f8d7a 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -23,15 +24,15 @@ import timber.log.Timber; -public class SharedPrefsFlagStore implements FlagStore { +class SharedPrefsFlagStore implements FlagStore { private static final String SHARED_PREFS_BASE_KEY = "LaunchDarkly-"; private final String prefsKey; private Application application; private SharedPreferences sharedPreferences; - private StoreUpdatedListener storeUpdatedListener; + private WeakReference listenerWeakReference; - public SharedPrefsFlagStore(@NonNull Application application, @NonNull String identifier) { + 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); @@ -72,7 +73,7 @@ public Flag getFlag(String flagKey) { return GsonCache.getGson().fromJson(flagData, Flag.class); } - private Pair applyFlagUpdateNoCommit(SharedPreferences.Editor editor, FlagUpdate flagUpdate) { + private Pair applyFlagUpdateNoCommit(@NonNull SharedPreferences.Editor editor, @NonNull FlagUpdate flagUpdate) { String flagKey = flagUpdate.flagToUpdate(); Flag flag = getFlag(flagKey); Flag newFlag = flagUpdate.updateFlag(flag); @@ -91,17 +92,18 @@ private Pair applyFlagUpdateNoCommit(SharedPreferen return null; } - // TODO synchronize listeners @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); } } + @NonNull private ArrayList> applyFlagUpdatesNoCommit(SharedPreferences.Editor editor, List flagUpdates) { ArrayList> updates = new ArrayList<>(); for (FlagUpdate flagUpdate : flagUpdates) { @@ -114,6 +116,7 @@ private ArrayList> applyFlagUpdatesNoCommit(Sh } private void informListenersOfUpdateList(List> updates) { + StoreUpdatedListener storeUpdatedListener = listenerWeakReference.get(); if (storeUpdatedListener != null) { for (Pair update : updates) { storeUpdatedListener.onStoreUpdate(update.first, update.second); @@ -160,11 +163,11 @@ public List getAllFlags() { @Override public void registerOnStoreUpdatedListener(StoreUpdatedListener storeUpdatedListener) { - this.storeUpdatedListener = storeUpdatedListener; + listenerWeakReference = new WeakReference<>(storeUpdatedListener); } @Override public void unregisterOnStoreUpdatedListener() { - this.storeUpdatedListener = null; + listenerWeakReference.clear(); } } From 5766828f455d52e341565bc8569efd114bf0b2d0 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 22 Feb 2019 19:39:00 +0000 Subject: [PATCH 064/152] Migration code for upcoming flagstore. --- .../com/launchdarkly/android/Migration.java | 211 +++++++++++++----- 1 file changed, 156 insertions(+), 55 deletions(-) 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 index ee761081..2c67c7ae 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java @@ -4,10 +4,16 @@ import android.content.Context; import android.content.SharedPreferences; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.gson.JsonObject; +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; @@ -16,78 +22,173 @@ 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")) { - 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()); - } + migrate_2_7_fresh(application, config); + } - 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; + if (migrations.contains("v2.6.0") && !migrations.contains("v2.7.0")) { + migrate_2_7_from_2_6(application); + } + } + + 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(); + JsonObject jsonFlags = GsonCache.getGson().toJsonTree(flagData).getAsJsonObject(); + + 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(); + SharedPreferences.Editor userFlagStoreEditor = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-flags", Context.MODE_PRIVATE).edit(); + for (String flagKey : flagKeys) { + Object value = flagValues.get(flagKey); + JsonObject flagJson = jsonFlags.get(flagKey).getAsJsonObject(); + flagJson.addProperty("key", flagKey); + flagJson.addProperty("value", GsonCache.getGson().toJson(value)); + userFlagStoreEditor.putString(flagKey, GsonCache.getGson().toJson(flagJson)); } - for (String mobileKey : config.getMobileKeys().values()) { - if (name.contains(mobileKey)) { - nameIter.remove(); - break; - } + 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"); - ArrayList userKeys = new ArrayList<>(); - for (String filename : filenames) { - userKeys.add(filename.substring(LDConfig.SHARED_PREFS_BASE_KEY.length(), filename.length() - 4)); + 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(); + JsonObject jsonFlags = new JsonObject(); + for (Map.Entry flagDataEntry : flagData.entrySet()) { + JsonObject jsonVal = GsonCache.getGson().fromJson((String)flagDataEntry.getValue(), JsonObject.class); + jsonFlags.add(flagDataEntry.getKey(), jsonVal); } - 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; + 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) { + Object value = flagValues.get(flagKey); + JsonObject flagJson = jsonFlags.get(flagKey).getAsJsonObject(); + flagJson.addProperty("key", flagKey); + flagJson.addProperty("value", GsonCache.getGson().toJson(value)); + userFlagStoreEditor.putString(flagKey, GsonCache.getGson().toJson(flagJson)); } - allSuccess = allSuccess && users && version && active && stores; + allSuccess = allSuccess && userFlagStoreEditor.commit(); } + } - 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(); + 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(); } } } } } + private 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 (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; + } + + private 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) { + Timber.d("Finding keys for file %s", filename); + String strip = filename.substring(LDConfig.SHARED_PREFS_BASE_KEY.length(), filename.length() - 9); + int splitAt = strip.length() - 44; + String mobileKey = strip.substring(0, splitAt); + String userKey = strip.substring(splitAt); + Timber.d("mobile key: %s user key %s", mobileKey, userKey); + keyUserMap.put(mobileKey, userKey); + } + return keyUserMap; + } + private static boolean copySharedPreferences(SharedPreferences oldPreferences, SharedPreferences newPreferences) { SharedPreferences.Editor editor = newPreferences.edit(); From 57f645db188ca74c593a10894d27a069650916c1 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 22 Feb 2019 20:03:35 +0000 Subject: [PATCH 065/152] Remove couple of debug messages. --- .../src/main/java/com/launchdarkly/android/Migration.java | 2 -- 1 file changed, 2 deletions(-) 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 index 2c67c7ae..08554563 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java @@ -178,12 +178,10 @@ private static Multimap getUserKeys_2_6(Application application) Multimap keyUserMap = HashMultimap.create(); for (String filename : filenames) { - Timber.d("Finding keys for file %s", filename); String strip = filename.substring(LDConfig.SHARED_PREFS_BASE_KEY.length(), filename.length() - 9); int splitAt = strip.length() - 44; String mobileKey = strip.substring(0, splitAt); String userKey = strip.substring(splitAt); - Timber.d("mobile key: %s user key %s", mobileKey, userKey); keyUserMap.put(mobileKey, userKey); } return keyUserMap; From aeec6385c5eb9cbf35874e39bb31fb50a189e4cf Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 22 Feb 2019 21:07:18 +0000 Subject: [PATCH 066/152] Handle todos. --- .../main/java/com/launchdarkly/android/LDClient.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 b79e8e86..15d24773 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 @@ -391,7 +391,7 @@ public Void apply(List input) { /** * Returns a map of all feature flags for the current user. No events are sent to LaunchDarkly. * - * @return + * @return a map of all feature flags */ @Override public Map allFlags() { @@ -399,18 +399,16 @@ public Void apply(List input) { List flags = userManager.getCurrentUserFlagStore().getAllFlags(); for (Flag flag : flags) { JsonElement jsonVal = flag.getValue(); - if (jsonVal == null) { + if (jsonVal == null || jsonVal.isJsonNull()) { result.put(flag.getKey(), null); } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { result.put(flag.getKey(), jsonVal.getAsBoolean()); } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { - // TODO distinguish ints? result.put(flag.getKey(), jsonVal.getAsFloat()); } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { result.put(flag.getKey(), jsonVal.getAsString()); } else { - // TODO - result.put(flag.getKey(), GsonCache.getGson().toJson(jsonVal)); + result.put(flag.getKey(), jsonVal); } } return result; @@ -619,7 +617,7 @@ public JsonElement jsonVariation(String flagKey, JsonElement fallback) { Timber.e("Attempted to get non-existent json flag for key: %s Returning fallback: %s", flagKey, fallback); } else { JsonElement jsonVal = flag.getValue(); - if (jsonVal == null || jsonVal.isJsonNull()) { // TODO, return null, or fallback? can jsonVal even be null (as opposed to jsonNull)? + if (jsonVal == null) { Timber.e("Attempted to get json flag without value for key: %s Returning fallback: %s", flagKey, fallback); } else { result = jsonVal; From c654453d8fc27688388acc846ac166e087b57f46 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 22 Feb 2019 21:16:19 +0000 Subject: [PATCH 067/152] Revert to old String behavior for allFlags, initialize WeakReference in SharedPrefsFlagStore. --- .../src/main/java/com/launchdarkly/android/LDClient.java | 2 +- .../android/flagstore/sharedprefs/SharedPrefsFlagStore.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 15d24773..4b3af9f6 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 @@ -408,7 +408,7 @@ public Void apply(List input) { } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { result.put(flag.getKey(), jsonVal.getAsString()); } else { - result.put(flag.getKey(), jsonVal); + result.put(flag.getKey(), GsonCache.getGson().toJson(jsonVal)); } } return result; 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 index e36f8d7a..e7de7ca2 100644 --- 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 @@ -36,6 +36,7 @@ class SharedPrefsFlagStore implements FlagStore { 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") From 69c1c9b2b8d9a3b72fcd856f2b6da0e8c896802c Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 26 Feb 2019 00:17:50 +0000 Subject: [PATCH 068/152] Better implementation of EvaluationReason serialization type adapter. --- .../gson/EvaluationReasonSerialization.java | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) 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 index 13c27f79..30818361 100644 --- 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 @@ -26,22 +26,7 @@ private static > T parseEnum(Class c, String name, T fallba @Override public JsonElement serialize(EvaluationReason src, Type typeOfSrc, JsonSerializationContext context) { - if (src instanceof EvaluationReason.Off) { - return context.serialize(src, EvaluationReason.Off.class); - } else if (src instanceof EvaluationReason.Fallthrough) { - return context.serialize(src, EvaluationReason.Fallthrough.class); - } else if (src instanceof EvaluationReason.TargetMatch) { - return context.serialize(src, EvaluationReason.TargetMatch.class); - } else if (src instanceof EvaluationReason.RuleMatch) { - return context.serialize(src, EvaluationReason.RuleMatch.class); - } else if (src instanceof EvaluationReason.PrerequisiteFailed) { - return context.serialize(src, EvaluationReason.PrerequisiteFailed.class); - } else if (src instanceof EvaluationReason.Error) { - return context.serialize(src, EvaluationReason.Error.class); - } else if (src instanceof EvaluationReason.Unknown) { - return context.serialize(src, EvaluationReason.Unknown.class); - } - return null; + return context.serialize(src, src.getClass()); } @Override From 23fd36aef64933038471268767965635592c9422 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 26 Feb 2019 00:19:34 +0000 Subject: [PATCH 069/152] Revert "Better implementation of EvaluationReason serialization type adapter." Wrong branch... This reverts commit 69c1c9b2b8d9a3b72fcd856f2b6da0e8c896802c. --- .../gson/EvaluationReasonSerialization.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 index 30818361..13c27f79 100644 --- 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 @@ -26,7 +26,22 @@ private static > T parseEnum(Class c, String name, T fallba @Override public JsonElement serialize(EvaluationReason src, Type typeOfSrc, JsonSerializationContext context) { - return context.serialize(src, src.getClass()); + if (src instanceof EvaluationReason.Off) { + return context.serialize(src, EvaluationReason.Off.class); + } else if (src instanceof EvaluationReason.Fallthrough) { + return context.serialize(src, EvaluationReason.Fallthrough.class); + } else if (src instanceof EvaluationReason.TargetMatch) { + return context.serialize(src, EvaluationReason.TargetMatch.class); + } else if (src instanceof EvaluationReason.RuleMatch) { + return context.serialize(src, EvaluationReason.RuleMatch.class); + } else if (src instanceof EvaluationReason.PrerequisiteFailed) { + return context.serialize(src, EvaluationReason.PrerequisiteFailed.class); + } else if (src instanceof EvaluationReason.Error) { + return context.serialize(src, EvaluationReason.Error.class); + } else if (src instanceof EvaluationReason.Unknown) { + return context.serialize(src, EvaluationReason.Unknown.class); + } + return null; } @Override From becd7e54952798c5229d50290b9c1ec8df12a2de Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 26 Feb 2019 23:32:42 +0000 Subject: [PATCH 070/152] Gw/ch29266/flagstore (#105) * Changed shared preferences store system to user a single FlagStore system that holds all the information on a flag to prevent issues arising from unsynchronized separate stores for flag meta-data and values. * Abstract FlagStoreManager from FlagStore, new FlagStoreFactory class so manager can construct FlagStores of unknown type. Reformatted interfaces. Removed unused imports. * Handle null case in allFlags, actually commit changes to UserManager. * Hopefully fix edge cases in summary event reporting to pass testing. * Hopefully fix edge cases in summary event reporting to pass testing. * Simplify getFeaturesJsonObject as no longer using -1 as placeholder for null for variations. * Make Flag non-mutable. Move GsonCache to gson package, move custom serializer/deserializers to classes in gson package and create one for PUT responses. Removed BaseUserSharedPreferences. * Send summary event even if stored flag doesn't exist. * Move sendSummaryEvent update code to UserSummaryEventSharedPreferences to synchronize to prevent data race on sending, updating, and clearing event store. Move SummaryEventSharedPreferences and UserSummaryEventSharedPreferences out of response package. * Update SharedPrefsFlagStore to hold StoreUpdatedListener in weak reference. Fix various warnings. * Migration code for upcoming flagstore. * Remove couple of debug messages. * Handle todos. * Revert to old String behavior for allFlags, initialize WeakReference in SharedPrefsFlagStore. * Better implementation of EvaluationReason serialization type adapter. * Remove isUnknown argument from SummaryEventSharedPreferences methods. Use Runnable instead of Callable in UserManager to avoid useless return nulls. Rename FlagStoreFactoryInterface to FlagStoreFactory. * Statically initialize Gson instance in GsonCache. * Make Gson instance in GsonCache final on principle. --- .../launchdarkly/android/UserManagerTest.java | 237 +++++----- ...UserSummaryEventSharedPreferencesTest.java | 36 +- .../FlagTest.java} | 95 ++-- .../sharedprefs/SharedPrefsFlagStoreTest.java | 200 ++++++++ ...UserFlagResponseSharedPreferencesTest.java | 197 -------- .../android/ConnectivityReceiver.java | 4 + .../android/EvaluationReason.java | 36 +- .../launchdarkly/android/EventProcessor.java | 14 +- .../com/launchdarkly/android/Foreground.java | 2 +- .../android/HttpFeatureFlagFetcher.java | 8 +- .../com/launchdarkly/android/LDClient.java | 436 +++++++----------- .../java/com/launchdarkly/android/LDUser.java | 1 - .../com/launchdarkly/android/Migration.java | 212 +++++++++ .../android/PollingUpdateProcessor.java | 1 - .../android/StreamUpdateProcessor.java | 4 +- .../SummaryEventSharedPreferences.java | 10 +- .../com/launchdarkly/android/Throttler.java | 4 +- .../launchdarkly/android/UpdateProcessor.java | 2 +- .../com/launchdarkly/android/UserHasher.java | 2 - .../android/UserLocalSharePreferences.java | 371 --------------- .../com/launchdarkly/android/UserManager.java | 280 +++-------- .../UserSummaryEventSharedPreferences.java | 185 ++++++++ .../java/com/launchdarkly/android/Util.java | 44 +- .../launchdarkly/android/flagstore/Flag.java | 88 ++++ .../android/flagstore/FlagInterface.java | 22 + .../android/flagstore/FlagStore.java | 29 ++ .../android/flagstore/FlagStoreFactory.java | 9 + .../android/flagstore/FlagStoreManager.java | 18 + .../flagstore/FlagStoreUpdateType.java | 5 + .../android/flagstore/FlagUpdate.java | 9 + .../flagstore/StoreUpdatedListener.java | 5 + .../sharedprefs/SharedPrefsFlagStore.java | 174 +++++++ .../SharedPrefsFlagStoreFactory.java | 21 + .../SharedPrefsFlagStoreManager.java | 154 +++++++ .../EvaluationReasonSerialization.java} | 70 +-- .../gson/FlagsResponseSerialization.java | 38 ++ .../launchdarkly/android/gson/GsonCache.java | 22 + .../response/BaseUserSharedPreferences.java | 60 --- .../android/response/DeleteFlagResponse.java | 28 ++ .../android/response/FlagResponse.java | 34 -- .../FlagResponseSharedPreferences.java | 24 - .../android/response/FlagResponseStore.java | 13 - .../android/response/FlagsResponse.java | 21 + .../android/response/UserFlagResponse.java | 135 ------ .../UserFlagResponseSharedPreferences.java | 78 ---- .../response/UserFlagResponseStore.java | 30 -- .../UserSummaryEventSharedPreferences.java | 106 ----- .../DeleteFlagResponseInterpreter.java | 33 -- .../interpreter/FlagResponseInterpreter.java | 10 - .../PatchFlagResponseInterpreter.java | 28 -- .../PingFlagResponseInterpreter.java | 46 -- .../PutFlagResponseInterpreter.java | 38 -- .../interpreter/ResponseInterpreter.java | 10 - 53 files changed, 1755 insertions(+), 1984 deletions(-) rename launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/{response => }/UserSummaryEventSharedPreferencesTest.java (78%) rename launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/{response/UserFlagResponseTest.java => flagstore/FlagTest.java} (54%) create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java delete mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java rename launchdarkly-android-client/src/main/java/com/launchdarkly/android/{response => }/SummaryEventSharedPreferences.java (69%) delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserSummaryEventSharedPreferences.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/Flag.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagInterface.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStore.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactory.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreManager.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreUpdateType.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagUpdate.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/StoreUpdatedListener.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactory.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java rename launchdarkly-android-client/src/main/java/com/launchdarkly/android/{response/interpreter/UserFlagResponseParser.java => gson/EvaluationReasonSerialization.java} (55%) create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/FlagsResponseSerialization.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/GsonCache.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/BaseUserSharedPreferences.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/DeleteFlagResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseSharedPreferences.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseStore.java create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferences.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseStore.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/FlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PatchFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PingFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PutFlagResponseInterpreter.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/ResponseInterpreter.java 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 db0285a0..98d46212 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,14 @@ 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.Gson; 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 +29,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; @@ -59,21 +60,39 @@ 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 { 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 @@ -81,12 +100,12 @@ 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"); @@ -102,14 +121,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); @@ -125,7 +144,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()); @@ -147,32 +166,30 @@ 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(); - 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(); } @@ -182,8 +199,8 @@ public void TestDeleteForInvalidResponse() throws ExecutionException, Interrupte 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(); @@ -198,9 +215,7 @@ public void TestDeleteForInvalidResponse() throws ExecutionException, Interrupte @Test public void TestDeleteWithVersion() throws ExecutionException, InterruptedException { - userManager.clearFlagResponseSharedPreferences(); - - Future future = setUser("userKey", new JsonObject()); + Future future = setUserClear("userKey", new JsonObject()); future.get(); String json = "{\n" + @@ -214,109 +229,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(); - 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()); + 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.getStoredFlagResponse("flag1").getVersion()); + 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.getStoredFlagResponse("flag1").getVersion()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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.getStoredFlagResponse("flag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("flag1").getVersionForEvents()); + 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()); + Future future = setUserClear("userKey", new JsonObject()); future.get(); String json = "{\n" + @@ -331,26 +345,29 @@ 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.getStoredFlagResponse("stringFlag1").getFlagVersion()); - assertEquals(125, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersionForEvents()); + 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.getStoredFlagResponse("stringFlag1").getVersion()); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getFlagVersion()); - assertEquals(126, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersionForEvents()); + 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.getStoredFlagResponse("stringFlag1").getVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getFlagVersion()); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagResponse("stringFlag1").getVersionForEvents()); + 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 @@ -358,8 +375,8 @@ public void TestPatchForInvalidResponse() throws ExecutionException, Interrupted 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(); @@ -376,9 +393,9 @@ public void TestPatchForInvalidResponse() throws ExecutionException, Interrupted 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,15 +420,15 @@ 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 @@ -419,8 +436,8 @@ public void TestPutForInvalidResponse() throws ExecutionException, InterruptedEx 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 +461,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 +491,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/response/UserFlagResponseTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java similarity index 54% rename from launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java rename to launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java index 69ab653f..13d01004 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.android.response; +package com.launchdarkly.android.flagstore; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -6,7 +6,8 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.android.EvaluationReason; -import com.launchdarkly.android.response.interpreter.UserFlagResponseParser; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.gson.GsonCache; import org.junit.Test; @@ -14,11 +15,12 @@ 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 UserFlagResponseTest { - private static final Gson gson = new Gson(); +public class FlagTest { + private static final Gson gson = GsonCache.getGson(); private static final Map TEST_REASONS = ImmutableMap.builder() .put(EvaluationReason.off(), "{\"kind\": \"OFF\"}") @@ -31,148 +33,140 @@ public class UserFlagResponseTest { @Test public void valueIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes")); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), null, null, null, null, null, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive("yes"), json.get("value")); } @Test public void valueIsDeserialized() { final String jsonStr = "{\"value\": \"yes\"}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertEquals(new JsonPrimitive("yes"), r.getValue()); } @Test public void versionIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(99), json.get("version")); } @Test public void versionIsDeserialized() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertEquals(99, r.getVersion()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNotNull(r.getVersion()); + assertEquals(99, (int) r.getVersion()); } @Test public void flagVersionIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertEquals(100, r.getFlagVersion()); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertEquals(-1, r.getFlagVersion()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getFlagVersion()); } @Test public void variationIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, null, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, null, null, null); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertEquals(new Integer(2), r.getVariation()); } @Test public void variationDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertNull(r.getVariation()); } @Test public void trackEventsIsSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, true, null, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, true, null, null); + 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 JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertTrue(r.isTrackEvents()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertTrue(r.getTrackEvents()); } @Test public void trackEventsDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); - assertFalse(r.isTrackEvents()); + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertFalse(r.getTrackEvents()); } @Test public void debugEventsUntilDateIsSerialized() { final long date = 12345L; - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, 2, false, date, null); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, false, date, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(date), json.get("debugEventsUntilDate")); } @Test public void debugEventsUntilDateIsDeserialized() { final String jsonStr = "{\"version\": 99, \"debugEventsUntilDate\": 12345}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertEquals(new Long(12345L), r.getDebugEventsUntilDate()); } @Test public void debugEventsUntilDateDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertNull(r.getDebugEventsUntilDate()); } @Test public void reasonIsSerialized() { - for (Map.Entry e: TEST_REASONS.entrySet()) { + 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 UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, reason); - final JsonObject json = r.getAsJsonObject(); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, reason); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(expectedJson, json.get("reason")); } } @Test public void reasonIsDeserialized() { - for (Map.Entry e: TEST_REASONS.entrySet()) { + 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 UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(json, Flag.class); assertEquals(reason, r.getReason()); } } @@ -180,15 +174,14 @@ public void reasonIsDeserialized() { @Test public void reasonDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; - final JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - final UserFlagResponse r = UserFlagResponseParser.parseFlag(json, "flag"); + final Flag r = gson.fromJson(jsonStr, Flag.class); assertNull(r.getReason()); } @Test public void emptyPropertiesAreNotSerialized() { - final UserFlagResponse r = new UserFlagResponse("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, null); - final JsonObject json = r.getAsJsonObject(); - assertEquals(ImmutableSet.of("value", "version", "flagVersion"), json.keySet()); + final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, null); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(ImmutableSet.of("key", "trackEvents", "value", "version", "flagVersion"), json.keySet()); } } 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..e8d8d7bf --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java @@ -0,0 +1,200 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.flagstore.Flag; +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.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.Collections; + +@RunWith(AndroidJUnit4.class) +public class SharedPrefsFlagStoreTest { + + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + @Test + public void savesVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), null, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 12, 0); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getVersion(), null); + } + + @Test + public void deletesVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + 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 Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag updatedKey1 = new Flag(key1.getKey(), key1.getValue(), 15, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + + flagStore.applyFlagUpdate(updatedKey1); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 15, 0); + } + + @Test + public void clearsFlags() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + flagStore.clear(); + + Assert.assertNull(flagStore.getFlag(key1.getKey())); + Assert.assertNull(flagStore.getFlag(key2.getKey())); + Assert.assertEquals(0, flagStore.getAllFlags().size(), 0); + } + + @Test + public void savesVariation() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, null, null, null); + final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVariation(), 16, 0); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getVariation(), 23, 0); + Assert.assertEquals(flagStore.getFlag(key3.getKey()).getVariation(), null); + } + + @Test + public void savesTrackEvents() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, false, 123456789L, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, true, 987654321L, null); + final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getTrackEvents(), false); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getTrackEvents(), true); + Assert.assertFalse(flagStore.getFlag(key3.getKey()).getTrackEvents()); + } + + @Test + public void savesDebugEventsUntilDate() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, false, 123456789L, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, true, 987654321L, null); + final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + //noinspection ConstantConditions + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getDebugEventsUntilDate(), 123456789L, 0); + //noinspection ConstantConditions + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getDebugEventsUntilDate(), 987654321L, 0); + Assert.assertNull(flagStore.getFlag(key3.getKey()).getDebugEventsUntilDate()); + } + + + @Test + public void savesFlagVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); + final Flag key2 = new Flag("key2", new JsonPrimitive(true), null, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 12, 0); + Assert.assertEquals(flagStore.getFlag(key2.getKey()).getFlagVersion(), null); + } + + @Test + public void deletesFlagVersions() { + final Flag key1 = new Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + 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 Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); + final Flag updatedKey1 = new Flag(key1.getKey(), key1.getValue(), null, 15, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + + flagStore.applyFlagUpdate(updatedKey1); + + Assert.assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 15, 0); + } + + @Test + public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() { + final Flag withFlagVersion = new Flag("withFlagVersion", new JsonPrimitive(true), 12, 13, null, null, null, null); + final Flag withOnlyVersion = new Flag("withOnlyVersion", new JsonPrimitive(true), 12, null, null, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(withFlagVersion, withOnlyVersion)); + + Assert.assertEquals(flagStore.getFlag(withFlagVersion.getKey()).getVersionForEvents(), 13, 0); + Assert.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 Flag("key1", new JsonPrimitive(true), 11, + 1, 1, null, null, reason); + final Flag flag2 = new Flag("key2", new JsonPrimitive(true), 11, + 1, 1, null, null, null); + + SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); + flagStore.clear(); + flagStore.applyFlagUpdates(Arrays.asList(flag1, flag2)); + + Assert.assertEquals(reason, flagStore.getFlag(flag1.getKey()).getReason()); + Assert.assertNull(flagStore.getFlag(flag2.getKey()).getReason()); + } +} 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 994e17ea..00000000 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java +++ /dev/null @@ -1,197 +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.EvaluationReason; -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.getStoredFlagResponse(key1.getKey()).getVersion(), 12, 0); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getVersion(), -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.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); - } - - @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.getStoredFlagResponse(key1.getKey()).getVersion(), 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.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key2.getKey())); - Assert.assertEquals(0, versionSharedPreferences.getLength(), 0); - } - - @Test - public void savesVariation() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, null, null, null); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, null, 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(16, versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getVariation(), 0); - Assert.assertEquals(23, versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getVariation(),0); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).getVariation()); - } - - @Test - public void savesTrackEvents() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L, null); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L, 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.getStoredFlagResponse(key1.getKey()).isTrackEvents(), false); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).isTrackEvents(), true); - Assert.assertFalse(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).isTrackEvents()); - } - - @Test - public void savesDebugEventsUntilDate() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L, null); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L, 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)); - - //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key1.getKey()).getDebugEventsUntilDate(), 123456789L, 0); - //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getDebugEventsUntilDate(), 987654321L, 0); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(key3.getKey()).getDebugEventsUntilDate()); - } - - - @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.getStoredFlagResponse(key1.getKey()).getFlagVersion(), 12, 0); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(key2.getKey()).getFlagVersion(), -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.assertNull(versionSharedPreferences.getStoredFlagResponse(key1.getKey())); - } - - @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.getStoredFlagResponse(key1.getKey()).getFlagVersion(), 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.getStoredFlagResponse(withFlagVersion.getKey()).getVersionForEvents(), 13, 0); - Assert.assertEquals(versionSharedPreferences.getStoredFlagResponse(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 - // UserFlagResponseTest. - final EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); - final UserFlagResponse flag1 = new UserFlagResponse("key1", new JsonPrimitive(true), 11, - 1, 1, null, null, reason); - final UserFlagResponse flag2 = new UserFlagResponse("key2", new JsonPrimitive(true), 11, - 1, 1, null, null, null); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(flag1, flag2)); - - Assert.assertEquals(reason, versionSharedPreferences.getStoredFlagResponse(flag1.getKey()).getReason()); - Assert.assertNull(versionSharedPreferences.getStoredFlagResponse(flag2.getKey()).getReason()); - } -} 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/EvaluationReason.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java index 1b82cef2..61626018 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java @@ -7,7 +7,7 @@ /** * Describes the reason that a flag evaluation produced a particular value. This is returned by * methods such as {@code boolVariationDetail()}. - * + *

    * Note that this is an enum-like class hierarchy rather than an enum, because some of the * possible reasons have their own properties. * @@ -93,10 +93,10 @@ public static enum ErrorKind { /** * Returns an enum indicating the general category of the reason. + * * @return a {@link Kind} value */ - public Kind getKind() - { + public Kind getKind() { return kind; } @@ -105,13 +105,13 @@ public String toString() { return getKind().name(); } - protected EvaluationReason(Kind kind) - { + protected EvaluationReason(Kind kind) { this.kind = kind; } /** * Returns an instance of {@link Off}. + * * @return a reason object */ public static Off off() { @@ -120,6 +120,7 @@ public static Off off() { /** * Returns an instance of {@link TargetMatch}. + * * @return a reason object */ public static TargetMatch targetMatch() { @@ -128,8 +129,9 @@ public static TargetMatch targetMatch() { /** * Returns an instance of {@link RuleMatch}. + * * @param ruleIndex the rule index - * @param ruleId the rule identifier + * @param ruleId the rule identifier * @return a reason object */ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { @@ -138,6 +140,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { /** * Returns an instance of {@link PrerequisiteFailed}. + * * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ @@ -147,6 +150,7 @@ public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { /** * Returns an instance of {@link Fallthrough}. + * * @return a reason object */ public static Fallthrough fallthrough() { @@ -155,6 +159,7 @@ public static Fallthrough fallthrough() { /** * Returns an instance of {@link Error}. + * * @param errorKind describes the type of error * @return a reason object */ @@ -164,9 +169,12 @@ public static Error error(ErrorKind errorKind) { /** * Returns an instance of {@link Unknown}. + * * @return a reason object */ - public static Unknown unknown() { return Unknown.instance; } + public static Unknown unknown() { + return Unknown.instance; + } /** * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned @@ -185,8 +193,7 @@ private Off() { * for this flag. */ public static class TargetMatch extends EvaluationReason { - private TargetMatch() - { + private TargetMatch() { super(Kind.TARGET_MATCH); } @@ -217,7 +224,7 @@ public String getRuleId() { @Override public boolean equals(Object other) { if (other instanceof RuleMatch) { - RuleMatch o = (RuleMatch)other; + RuleMatch o = (RuleMatch) other; return ruleIndex == o.ruleIndex && objectsEqual(ruleId, o.ruleId); } return false; @@ -253,7 +260,7 @@ public String getPrerequisiteKey() { @Override public boolean equals(Object other) { return (other instanceof PrerequisiteFailed) && - ((PrerequisiteFailed)other).prerequisiteKey.equals(prerequisiteKey); + ((PrerequisiteFailed) other).prerequisiteKey.equals(prerequisiteKey); } @Override @@ -272,8 +279,7 @@ public String toString() { * match any targets or rules. */ public static class Fallthrough extends EvaluationReason { - private Fallthrough() - { + private Fallthrough() { super(Kind.FALLTHROUGH); } @@ -317,7 +323,9 @@ public String toString() { * not supported by this version of the SDK. */ public static class Unknown extends EvaluationReason { - private Unknown() { super(Kind.UNKNOWN); } + private Unknown() { + super(Kind.UNKNOWN); + } private static final Unknown instance = new Unknown(); } 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/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 ebe8d5be..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(); @@ -139,10 +139,9 @@ private Request getDefaultRequest(LDUser user) { 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) { @@ -153,11 +152,10 @@ private Request getReportRequest(LDUser user) { 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 a497d84a..480880ff 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 @@ -11,6 +11,7 @@ import com.google.android.gms.common.GooglePlayServicesNotAvailableException; import com.google.android.gms.common.GooglePlayServicesRepairableException; +import com.google.android.gms.security.ProviderInstaller; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.base.Preconditions; @@ -20,22 +21,16 @@ 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.FlagResponse; -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; @@ -152,7 +147,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()); @@ -203,11 +198,11 @@ private static boolean validateParameter(T parameter) { * 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); @@ -310,103 +305,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. * @@ -493,11 +391,27 @@ public Void apply(List input) { /** * Returns a map of all feature flags for the current user. No events are sent to LaunchDarkly. * - * @return + * @return a map of all feature flags */ @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()) { + result.put(flag.getKey(), null); + } 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 { + result.put(flag.getKey(), GsonCache.getGson().toJson(jsonVal)); + } + } + return result; } /** @@ -508,35 +422,38 @@ public Void apply(List input) { *

  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public Boolean boolVariation(String flagKey, Boolean fallback) { - Boolean result = null; - if (flagKey != null) { - try { - result = (Boolean) userManager.getCurrentUserSharedPrefs().getAll().get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get boolean flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } - } else { - Timber.e("Attempted to get boolean flag with a default null value for key. Returning fallback: %s", fallback); + if (flagKey == null) { + Timber.e("Attempted to get boolean flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - result = result == null ? fallback : result; - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, null); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + Boolean result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent boolean flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get boolean flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { + result = jsonVal.getAsBoolean(); + } else { + Timber.e("Attempted to get boolean flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("boolVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -549,34 +466,38 @@ public Boolean boolVariation(String flagKey, Boolean fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public Integer intVariation(String flagKey, Integer fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - Integer result = fallback; if (flagKey == null) { - Timber.e("Attempted to get integer flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - result = (Integer) sharedPrefs.get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get integer flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } + Timber.e("Attempted to get integer flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + Integer result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent integer flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get integer flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { + result = jsonVal.getAsInt(); + } else { + Timber.e("Attempted to get integer flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("intVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -589,34 +510,38 @@ public Integer intVariation(String flagKey, Integer fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public Float floatVariation(String flagKey, Float fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - Float result = fallback; if (flagKey == null) { - Timber.e("Attempted to get float flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - result = (Float) sharedPrefs.get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get float flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } + Timber.e("Attempted to get float flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + Float result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent float flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get float flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { + result = jsonVal.getAsFloat(); + } else { + Timber.e("Attempted to get float flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("floatVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -629,34 +554,38 @@ public Float floatVariation(String flagKey, Float fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public String stringVariation(String flagKey, String fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - String result = fallback; if (flagKey == null) { - Timber.e("Attempted to get string flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - result = (String) sharedPrefs.get(flagKey); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get string flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } + Timber.e("Attempted to get string flag with a null value for key. Returning fallback: %s", fallback); + return fallback; } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - if (result == null) { - updateSummaryEvents(flagKey, flag, null, null); - sendFlagRequestEvent(flagKey, flag, JsonNull.INSTANCE, JsonNull.INSTANCE); - } else if (fallback == null) { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), JsonNull.INSTANCE); + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + String result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent string flag for key: %s Returning fallback: %s", flagKey, fallback); } else { - updateSummaryEvents(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, flag, new JsonPrimitive(result), new JsonPrimitive(fallback)); + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + Timber.e("Attempted to get string flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { + result = jsonVal.getAsString(); + } else { + Timber.e("Attempted to get string flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); + } } + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : new JsonPrimitive(fallback); + JsonElement val = result == null ? JsonNull.INSTANCE : new JsonPrimitive(result); + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("stringVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -669,29 +598,36 @@ public String stringVariation(String flagKey, String fallback) { *
  • Any other error
  • * * - * @param flagKey - * @param fallback - * @return + * @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 */ @Override public JsonElement jsonVariation(String flagKey, JsonElement fallback) { - Map sharedPrefs = userManager.getCurrentUserSharedPrefs().getAll(); - JsonElement result = fallback; if (flagKey == null) { - Timber.e("Attempted to get string flag with a default null value for key. Returning fallback: %s", fallback); - } else if (sharedPrefs.containsKey(flagKey)) { - try { - String stringResult = (String) sharedPrefs.get(flagKey); - result = new JsonParser().parse(stringResult); - } 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 (JsonSyntaxException jse) { - Timber.e(jse, "Attempted to get json flag from string flag for key: %s Returning fallback: %s", flagKey, fallback); + Timber.e("Attempted to get json flag with a null value for key. Returning fallback: %s", fallback); + return fallback; + } + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + + JsonElement result = fallback; + + if (flag == null) { + Timber.e("Attempted to get non-existent json flag for key: %s Returning fallback: %s", flagKey, fallback); + } else { + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null) { + Timber.e("Attempted to get json flag without value for key: %s Returning fallback: %s", flagKey, fallback); + } else { + result = jsonVal; } } - FlagResponse flag = userManager.getFlagResponseSharedPreferences().getStoredFlagResponse(flagKey); - updateSummaryEvents(flagKey, flag, result, fallback); - sendFlagRequestEvent(flagKey, flag, result, fallback); + + JsonElement defaultVal = fallback == null ? JsonNull.INSTANCE : fallback; + JsonElement val = result == null ? JsonNull.INSTANCE : result; + updateSummaryEvents(flagKey, flag, val, defaultVal); + sendFlagRequestEvent(flagKey, flag, val, defaultVal); Timber.d("jsonVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -706,7 +642,7 @@ 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) { @@ -715,16 +651,9 @@ private void closeInternal() throws IOException { } 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; } /** @@ -823,8 +752,8 @@ 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 + * @param flagKey the flag key to attach the listener to + * @param listener the listener to attach to the flag key */ @Override public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { @@ -834,8 +763,8 @@ public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListene /** * Unregisters a {@link FeatureFlagChangeListener} for the flagKey * - * @param flagKey - * @param listener + * @param flagKey the flag key to attach the listener to + * @param listener the listener to attach to the flag key */ @Override public void unregisterFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { @@ -861,17 +790,21 @@ void startForegroundUpdating() { } } - private void sendFlagRequestEvent(String flagKey, FlagResponse flag, JsonElement value, JsonElement fallback) { - int version = flag == null ? -1 : flag.getVersionForEvents(); - Integer variation = flag == null ? null : flag.getVariation(); - if (flag != null && flag.isTrackEvents()) { + private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, JsonElement fallback) { + 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)); } else { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser().getKeyAsString(), value, fallback, version, variation)); } } else { - Long debugEventsUntilDate = flag == null ? null : flag.getDebugEventsUntilDate(); + Long debugEventsUntilDate = flag.getDebugEventsUntilDate(); if (debugEventsUntilDate != null) { long serverTimeMs = eventProcessor.getCurrentTimeMs(); if (debugEventsUntilDate > System.currentTimeMillis() && debugEventsUntilDate > serverTimeMs) { @@ -879,8 +812,6 @@ private void sendFlagRequestEvent(String flagKey, FlagResponse flag, JsonElement } } } - - sendSummaryEvent(); } void startBackgroundPolling() { @@ -904,37 +835,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, FlagResponse flag, JsonElement result, JsonElement fallback) { - int version = flag == null ? -1 : flag.getVersionForEvents(); - Integer variation = flag == null ? null : flag.getVariation(); - 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/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/Migration.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java new file mode 100644 index 00000000..08554563 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java @@ -0,0 +1,212 @@ +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.JsonObject; +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 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(); + JsonObject jsonFlags = GsonCache.getGson().toJsonTree(flagData).getAsJsonObject(); + + 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(); + SharedPreferences.Editor userFlagStoreEditor = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-flags", Context.MODE_PRIVATE).edit(); + for (String flagKey : flagKeys) { + Object value = flagValues.get(flagKey); + JsonObject flagJson = jsonFlags.get(flagKey).getAsJsonObject(); + flagJson.addProperty("key", flagKey); + flagJson.addProperty("value", GsonCache.getGson().toJson(value)); + userFlagStoreEditor.putString(flagKey, GsonCache.getGson().toJson(flagJson)); + } + 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"); + + 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(); + JsonObject jsonFlags = new JsonObject(); + for (Map.Entry flagDataEntry : flagData.entrySet()) { + JsonObject jsonVal = GsonCache.getGson().fromJson((String)flagDataEntry.getValue(), JsonObject.class); + jsonFlags.add(flagDataEntry.getKey(), jsonVal); + } + + 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) { + Object value = flagValues.get(flagKey); + JsonObject flagJson = jsonFlags.get(flagKey).getAsJsonObject(); + flagJson.addProperty("key", flagKey); + flagJson.addProperty("value", GsonCache.getGson().toJson(value)); + userFlagStoreEditor.putString(flagKey, GsonCache.getGson().toJson(flagJson)); + } + 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(); + } + } + } + } + } + + private 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 (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; + } + + private 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; + 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 6dcccb30..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)); 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/SummaryEventSharedPreferences.java similarity index 69% rename from launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/SummaryEventSharedPreferences.java rename to launchdarkly-android-client/src/main/java/com/launchdarkly/android/SummaryEventSharedPreferences.java index b5b710fd..f41d0cf2 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/SummaryEventSharedPreferences.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/SummaryEventSharedPreferences.java @@ -1,9 +1,8 @@ -package com.launchdarkly.android.response; +package com.launchdarkly.android; import android.support.annotation.Nullable; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; /** * Created by jamesthacker on 4/12/18. @@ -12,8 +11,7 @@ public interface SummaryEventSharedPreferences { void clear(); - - void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer variation, boolean unknown); - - JsonObject getFeaturesJsonObject(); + 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..16607ca2 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,23 +12,16 @@ 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; @@ -50,13 +40,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 +55,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 +66,8 @@ LDUser getCurrentUser() { return currentUser; } - SharedPreferences getCurrentUserSharedPrefs() { - return userLocalSharedPreferences.getCurrentUserSharedPrefs(); - } - - FlagResponseSharedPreferences getFlagResponseSharedPreferences() { - return flagResponseSharedPreferences; + FlagStore getCurrentUserFlagStore() { + return flagStoreManager.getCurrentUserStore(); } SummaryEventSharedPreferences getSummaryEventSharedPreferences() { @@ -101,14 +78,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 +102,7 @@ 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(); +// syncCurrentUserToActiveUserAndLog(); } }, MoreExecutors.directExecutor()); @@ -140,17 +116,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 +130,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 +153,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/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..b7e95aef --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagInterface.java @@ -0,0 +1,22 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +import com.google.gson.JsonElement; +import com.launchdarkly.android.EvaluationReason; + +public interface FlagInterface { + + @NonNull + String getKey(); + + JsonElement getValue(); + + Integer getVersion(); + + Integer getFlagVersion(); + + Integer getVariation(); + + 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..3098c936 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStore.java @@ -0,0 +1,29 @@ +package com.launchdarkly.android.flagstore; + +import java.util.List; + +import javax.annotation.Nullable; + +public interface FlagStore { + + void delete(); + + void clear(); + + boolean containsKey(String key); + + @Nullable + Flag getFlag(String flagKey); + + void applyFlagUpdate(FlagUpdate flagUpdate); + + void applyFlagUpdates(List flagUpdates); + + void clearAndApplyFlagUpdates(List flagUpdates); + + List getAllFlags(); + + void registerOnStoreUpdatedListener(StoreUpdatedListener storeUpdatedListener); + + 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..74e109e4 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactory.java @@ -0,0 +1,9 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +public interface FlagStoreFactory { + + 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..745588a2 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreManager.java @@ -0,0 +1,18 @@ +package com.launchdarkly.android.flagstore; + +import com.launchdarkly.android.FeatureFlagChangeListener; + +import java.util.Collection; + +public interface FlagStoreManager { + + void switchToUser(String userKey); + + FlagStore getCurrentUserStore(); + + void registerListener(String key, FeatureFlagChangeListener listener); + + void unRegisterListener(String key, FeatureFlagChangeListener listener); + + 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..bb3dde41 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreUpdateType.java @@ -0,0 +1,5 @@ +package com.launchdarkly.android.flagstore; + +public enum FlagStoreUpdateType { + FLAG_DELETED, FLAG_UPDATED, 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..c218cc81 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagUpdate.java @@ -0,0 +1,9 @@ +package com.launchdarkly.android.flagstore; + +public interface FlagUpdate { + + Flag updateFlag(Flag before); + + 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..cb0017a9 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/StoreUpdatedListener.java @@ -0,0 +1,5 @@ +package com.launchdarkly.android.flagstore; + +public interface StoreUpdatedListener { + 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..e7de7ca2 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java @@ -0,0 +1,174 @@ +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 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(); + 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); + } + } + + @NonNull + private ArrayList> applyFlagUpdatesNoCommit(SharedPreferences.Editor editor, List flagUpdates) { + ArrayList> updates = new ArrayList<>(); + for (FlagUpdate flagUpdate : flagUpdates) { + Pair update = applyFlagUpdateNoCommit(editor, flagUpdate); + if (update != null) { + updates.add(update); + } + } + return updates; + } + + 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 = applyFlagUpdatesNoCommit(editor, flagUpdates); + editor.apply(); + informListenersOfUpdateList(updates); + } + + @Override + public void clearAndApplyFlagUpdates(List flagUpdates) { + sharedPreferences.edit().clear().apply(); + applyFlagUpdates(flagUpdates); + } + + @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..0a9b2d82 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java @@ -0,0 +1,154 @@ +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); + + usersSharedPrefs.edit() + .putLong(userKey, System.currentTimeMillis()) + .apply(); + + int usersStored = usersSharedPrefs.getAll().size(); + if (usersStored > MAX_USERS) { + Iterator oldestFirstUsers = getAllUsers().iterator(); + while (usersStored-- > MAX_USERS) { + String removed = oldestFirstUsers.next(); + Timber.d("Exceeded max # of users: [%s] Removing user: [%s]", MAX_USERS, removed); + flagStoreFactory.createFlagStore(storeIdentifierForUser(removed)).delete(); + 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) { + if (Looper.myLooper() == Looper.getMainLooper()) { + synchronized (listeners) { + if (flagStoreUpdateType != FlagStoreUpdateType.FLAG_DELETED) { + for (FeatureFlagChangeListener listener : listeners.get(flagKey)) { + listener.onFeatureFlagChange(flagKey); + } + } else { + listeners.removeAll(flagKey); + } + } + } else { + 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/response/interpreter/UserFlagResponseParser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java similarity index 55% rename from launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java rename to launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java index 9db002c2..30818361 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/UserFlagResponseParser.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java @@ -1,54 +1,37 @@ -package com.launchdarkly.android.response.interpreter; +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.JsonPrimitive; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; import com.launchdarkly.android.EvaluationReason; -import com.launchdarkly.android.response.UserFlagResponse; -public class UserFlagResponseParser { +import java.lang.reflect.Type; - public static UserFlagResponse parseFlag(JsonObject o, String key) { - if (o == null) { - return null; +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; } - JsonElement valueElement = o.get("value"); - JsonPrimitive versionElement = getPrimitive(o, "version"); - JsonPrimitive flagVersionElement = getPrimitive(o, "flagVersion"); - JsonPrimitive variationElement = getPrimitive(o, "variation"); - JsonPrimitive trackEventsElement = getPrimitive(o, "trackEvents"); - JsonPrimitive debugEventsUntilDateElement = getPrimitive(o, "debugEventsUntilDate"); - JsonElement reasonElement = o.get("reason"); - int version = versionElement != null && versionElement.isNumber() - ? versionElement.getAsInt() - : -1; - Integer variation = variationElement != null && variationElement.isNumber() - ? variationElement.getAsInt() - : null; - int flagVersion = flagVersionElement != null && flagVersionElement.isNumber() - ? flagVersionElement.getAsInt() - : -1; - boolean trackEvents = trackEventsElement != null && trackEventsElement.isBoolean() - && trackEventsElement.getAsBoolean(); - Long debugEventsUntilDate = debugEventsUntilDateElement != null && debugEventsUntilDateElement.isNumber() - ? debugEventsUntilDateElement.getAsLong() - : null; - EvaluationReason reason = reasonElement != null && reasonElement.isJsonObject() - ? parseReason(reasonElement.getAsJsonObject()) - : null; - return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate, reason); } - @Nullable - private static JsonPrimitive getPrimitive(JsonObject o, String name) { - JsonElement e = o.get(name); - return e != null && e.isJsonPrimitive() ? e.getAsJsonPrimitive() : null; + @Override + public JsonElement serialize(EvaluationReason src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src, src.getClass()); } - @Nullable - private static EvaluationReason parseReason(JsonObject o) { + @Override + public EvaluationReason deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + JsonObject o = json.getAsJsonObject(); if (o == null) { return null; } @@ -87,17 +70,12 @@ private static EvaluationReason parseReason(JsonObject o) { return EvaluationReason.error(errorKind); } return null; + case UNKNOWN: + return EvaluationReason.unknown(); } } return null; } - @Nullable - private static > T parseEnum(Class c, String name, T fallback) { - try { - return Enum.valueOf(c, name); - } catch (IllegalArgumentException e) { - return fallback; - } - } + } 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..065c9984 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/DeleteFlagResponse.java @@ -0,0 +1,28 @@ +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; + } + + @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 df4e8392..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.launchdarkly.android.response; - -import com.launchdarkly.android.EvaluationReason; -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(); - - int getVersionForEvents(); - - Integer getVariation(); - - boolean isTrackEvents(); - - Long getDebugEventsUntilDate(); - - EvaluationReason getReason(); - - 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 edc0ef58..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseSharedPreferences.java +++ /dev/null @@ -1,24 +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); - - FlagResponse getStoredFlagResponse(String flagResponseKey); - - boolean containsKey(String key); -} 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..6e26de99 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java @@ -0,0 +1,21 @@ +package com.launchdarkly.android.response; + +import android.support.annotation.NonNull; + +import com.launchdarkly.android.flagstore.Flag; + +import java.util.List; + +public class FlagsResponse { + @NonNull + private List flags; + + public FlagsResponse(@NonNull List flags) { + this.flags = flags; + } + + @NonNull + public List getFlags() { + return flags; + } +} 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 e7e854be..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.launchdarkly.android.response; - -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.android.EvaluationReason; - -/** - * Farhan - * 2018-01-30 - */ -public class UserFlagResponse implements FlagResponse { - private static Gson gson = new Gson(); - - @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; - - @Nullable - private final EvaluationReason reason; - - public UserFlagResponse(@NonNull String key, @Nullable JsonElement value, int version, int flagVersion, @Nullable Integer variation, @Nullable Boolean trackEvents, @Nullable Long debugEventsUntilDate, @Nullable EvaluationReason reason) { - this.key = key; - this.value = value; - this.version = version; - this.flagVersion = flagVersion; - this.variation = variation; - this.trackEvents = trackEvents == null ? false : trackEvents.booleanValue(); - this.debugEventsUntilDate = debugEventsUntilDate; - this.reason = reason; - } - - public UserFlagResponse(String key, JsonElement value) { - this(key, value, -1, -1, null, null, null, null); - } - - public UserFlagResponse(String key, JsonElement value, int version, int flagVersion) { - this(key, value, version, flagVersion, null, 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; - } - - @Override - public int getVersionForEvents() { - return flagVersion > 0 ? flagVersion : version; - } - - @Nullable - @Override - public Integer getVariation() { - return variation; - } - - @Override - public boolean isTrackEvents() { - return trackEvents; - } - - @Nullable - @Override - public Long getDebugEventsUntilDate() { - return debugEventsUntilDate; - } - - @Nullable - @Override - public EvaluationReason getReason() { - return reason; - } - - @Override - public JsonObject getAsJsonObject() { - JsonObject object = new JsonObject(); - object.add("value", value); - object.add("version", new JsonPrimitive(version)); - object.add("flagVersion", new JsonPrimitive(flagVersion)); - if (variation != null) { - object.add("variation", new JsonPrimitive(variation)); - } - if (trackEvents) { - object.add("trackEvents", new JsonPrimitive(true)); - } - if (debugEventsUntilDate != null) { - object.add("debugEventsUntilDate", new JsonPrimitive(debugEventsUntilDate)); - } - if (reason != null) { - object.add("reason", gson.toJsonTree(reason)); - } - 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 cf06f08d..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferences.java +++ /dev/null @@ -1,78 +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 com.google.gson.JsonObject; -import com.launchdarkly.android.response.interpreter.UserFlagResponseParser; - -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) { - FlagResponse storedFlag = getStoredFlagResponse(flagResponse.getKey()); - if (storedFlag != null) { - return storedFlag.getVersion() < 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 FlagResponse getStoredFlagResponse(String key) { - JsonObject jsonObject = getValueAsJsonObject(key); - return jsonObject == null ? null : UserFlagResponseParser.parseFlag(jsonObject, key); - } - - @Override - public boolean containsKey(String key) { - return sharedPreferences.contains(key); - } - - @VisibleForTesting - int getLength() { - return sharedPreferences.getAll().size(); - } -} 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 c1b28580..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java +++ /dev/null @@ -1,106 +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, @Nullable Integer nullableVariation, boolean isUnknown) { - int variation = nullableVariation == null ? -1 : nullableVariation; - 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").getAsInt() == version; - boolean isSameVariation = variationElement != null && variationElement.getAsInt() == variation; - if ((isSameVersion && isSameVariation) || (variationElement == null && versionElement == null && isUnknown && value.equals(asJsonObject.get("value")))) { - 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)); - newCounter.add("value", value); - } 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/DeleteFlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java deleted file mode 100644 index 90f46003..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java +++ /dev/null @@ -1,33 +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 implements FlagResponseInterpreter { - - @Nullable - @Override - public FlagResponse apply(@Nullable JsonObject input) { - if (input != null) { - JsonElement keyElement = input.get("key"); - JsonElement versionElement = input.get("version"); - int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() - ? versionElement.getAsInt() - : -1; - - if (keyElement != null) { - String key = keyElement.getAsJsonPrimitive().getAsString(); - return new UserFlagResponse(key, null, version, -1, -1, false, null, null); - } - } - 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 ad6cf6ef..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PatchFlagResponseInterpreter.java +++ /dev/null @@ -1,28 +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 javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class PatchFlagResponseInterpreter implements FlagResponseInterpreter { - - @Nullable - @Override - public FlagResponse apply(@Nullable JsonObject input) { - if (input != null) { - JsonElement keyElement = input.get("key"); - - if (keyElement != null) { - String key = keyElement.getAsJsonPrimitive().getAsString(); - return UserFlagResponseParser.parseFlag(input, key); - } - } - 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 95430ec6..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PingFlagResponseInterpreter.java +++ /dev/null @@ -1,46 +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 implements FlagResponseInterpreter> { - - @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(); - - flagResponseList.add(UserFlagResponseParser.parseFlag(asJsonObject, key)); - } else { - flagResponseList.add(new UserFlagResponse(key, v)); - } - } - } - return flagResponseList; - } - - protected boolean isValueInsideObject(JsonElement element) { - return !element.isJsonNull() && element.isJsonObject() && element.getAsJsonObject().has("value"); - } -} 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 598a971c..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PutFlagResponseInterpreter.java +++ /dev/null @@ -1,38 +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 java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class PutFlagResponseInterpreter implements FlagResponseInterpreter> { - - @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) { - flagResponseList.add(UserFlagResponseParser.parseFlag(asJsonObject, key)); - } - } - } - 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 { -} From 76f46502380c803991fed1518f96187796c66549 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 26 Feb 2019 23:47:50 +0000 Subject: [PATCH 071/152] Return json flags as JsonElement in allFlags map. (#106) --- .../src/main/java/com/launchdarkly/android/LDClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 480880ff..eeadae6f 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 @@ -408,7 +408,7 @@ public Void apply(List input) { } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { result.put(flag.getKey(), jsonVal.getAsString()); } else { - result.put(flag.getKey(), GsonCache.getGson().toJson(jsonVal)); + result.put(flag.getKey(), jsonVal); } } return result; From 54ac5cf4a566431a18e17f7ed71783d3a90bd214 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 1 Mar 2019 21:18:56 +0000 Subject: [PATCH 072/152] Bump ok-http version to 3.9.1 (#107) --- launchdarkly-android-client/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launchdarkly-android-client/build.gradle b/launchdarkly-android-client/build.gradle index fd07ddfb..1ecbdaa9 100644 --- a/launchdarkly-android-client/build.gradle +++ b/launchdarkly-android-client/build.gradle @@ -57,7 +57,7 @@ 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" From 8e36a3270ec1fd1cda2930f0a32b06a884376b24 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 1 Mar 2019 16:05:56 -0800 Subject: [PATCH 073/152] fix annotations so eval reasons are serialized in events --- .../android/EvaluationReasonTest.java | 2 +- .../java/com/launchdarkly/android/EventTest.java | 15 +++++++++++++++ .../launchdarkly/android/EvaluationReason.java | 8 ++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) 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 index 2820db6f..977f1919 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java @@ -8,7 +8,7 @@ import static org.junit.Assert.assertEquals; public class EvaluationReasonTest { - private static final Gson gson = new Gson(); + private static final Gson gson = new LDConfig.Builder().build().getFilteredEventGson(); @Test public void testOffReasonSerialization() { 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 e624ac13..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 @@ -263,5 +263,20 @@ public void testOptionalFieldsAreExcludedAppropriately() { 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/main/java/com/launchdarkly/android/EvaluationReason.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java index 61626018..b519eef3 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java @@ -2,6 +2,8 @@ import android.support.annotation.Nullable; +import com.google.gson.annotations.Expose; + import static com.google.common.base.Preconditions.checkNotNull; /** @@ -89,6 +91,7 @@ public static enum ErrorKind { UNKNOWN } + @Expose private final Kind kind; /** @@ -204,7 +207,10 @@ private 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) { @@ -246,6 +252,7 @@ public String toString() { * 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) { @@ -290,6 +297,7 @@ private 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) { From 84ecc293271301fca7ef33b63b0da4b09e8171b8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 4 Mar 2019 16:11:49 -0800 Subject: [PATCH 074/152] fix/expand doc comments for public methods --- .../android/EvaluationDetail.java | 2 +- .../android/EvaluationReason.java | 2 +- .../android/FeatureFlagChangeListener.java | 9 ++ .../com/launchdarkly/android/LDClient.java | 56 -------- .../android/LDClientInterface.java | 95 +++++++++++++ .../com/launchdarkly/android/LDConfig.java | 134 ++++++++++++------ .../android/LaunchDarklyException.java | 3 + 7 files changed, 198 insertions(+), 103 deletions(-) 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 index d619a0dd..030c0276 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationDetail.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationDetail.java @@ -3,7 +3,7 @@ import com.google.common.base.Objects; /** - * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}, + * 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 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 index b519eef3..4ad16576 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java @@ -8,7 +8,7 @@ /** * Describes the reason that a flag evaluation produced a particular value. This is returned by - * methods such as {@code boolVariationDetail()}. + * 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. 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/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index 507054c2..6d015a71 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 @@ -305,12 +305,6 @@ public void run() { } } - /** - * 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()) { @@ -320,11 +314,6 @@ 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()) { @@ -334,13 +323,6 @@ public 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. - */ @Override public synchronized Future identify(LDUser user) { return LDClient.identifyInstances(user); @@ -388,11 +370,6 @@ 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 a map of all feature flags - */ @Override public Map allFlags() { Map result = new HashMap<>(); @@ -526,9 +503,6 @@ private static void closeInstances() throws IOException { } } - /** - * Sends all pending events to LaunchDarkly. - */ @Override public void flush() { LDClient.flushInstances(); @@ -554,15 +528,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(); @@ -583,14 +548,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(); @@ -618,24 +575,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 the flag key to attach the listener to - * @param listener the listener to attach to the flag key - */ @Override public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { userManager.registerListener(flagKey, listener); } - /** - * Unregisters a {@link FeatureFlagChangeListener} for the flagKey - * - * @param flagKey the flag key to attach the listener to - * @param listener the listener to attach to the flag key - */ @Override public void unregisterFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { userManager.unregisterListener(flagKey, listener); 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 839dc893..6a33e93e 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,29 +1,93 @@ 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(); /** @@ -50,6 +114,8 @@ public interface LDClientInterface extends Closeable { * @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); @@ -77,6 +143,8 @@ public interface LDClientInterface extends Closeable { * @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); @@ -104,6 +172,8 @@ public interface LDClientInterface extends Closeable { * @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); @@ -131,6 +201,8 @@ public interface LDClientInterface extends Closeable { * @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); @@ -157,12 +229,35 @@ public interface LDClientInterface extends Closeable { * @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(); } 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 c4a4d191..c326b59a 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-"; @@ -220,7 +224,11 @@ public static class Builder { 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; @@ -228,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); @@ -240,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)) { @@ -252,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) { @@ -282,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; @@ -289,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; @@ -300,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; @@ -311,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; @@ -322,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 teh 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; @@ -333,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; @@ -346,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; @@ -358,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; @@ -370,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; @@ -382,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; @@ -393,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; @@ -403,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; @@ -416,11 +454,13 @@ 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; @@ -443,6 +483,10 @@ public LDConfig.Builder setEvaluationReasons(boolean evaluationReasons) { return this; } + /** + * Returns the configured {@link LDConfig} object. + * @return the configuration + */ public LDConfig build() { if (!stream) { if (pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { 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); From 46948c09ebe39d614fddf581ffe25f51987e17df Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 4 Mar 2019 16:16:26 -0800 Subject: [PATCH 075/152] typo --- .../src/main/java/com/launchdarkly/android/LDConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c326b59a..35a64412 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 @@ -340,7 +340,7 @@ public LDConfig.Builder setStreamUri(Uri streamUri) { /** * Set the capacity of the event buffer. The client buffers up to this many events in memory before flushing. - * If teh capacity is exceeded before the buffer is flushed, events will be discarded. Increasing the capacity + * 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}. From 192e4dad864fad91540661d04efb276fcc327f3d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 4 Mar 2019 16:17:11 -0800 Subject: [PATCH 076/152] typo --- .../src/main/java/com/launchdarkly/android/LDConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 35a64412..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 @@ -372,7 +372,7 @@ public LDConfig.Builder setEventsFlushIntervalMillis(int eventsFlushIntervalMill /** * Sets the timeout when connecting to LaunchDarkly. *

    - * The default value is {@value LDConfig#DEFAULT_CONNECTION_TIMEOUT_MILLIS}. + * The default value is {@value LDConfig#DEFAULT_CONNECTION_TIMEOUT_MILLIS}. * * @param connectionTimeoutMillis the connection timeout, in milliseconds * @return the builder From ea73393e9cc9415961563e1361a7f0bbb17d3b64 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 Mar 2019 18:00:52 -0800 Subject: [PATCH 077/152] add version string getter method --- .../src/main/java/com/launchdarkly/android/LDClient.java | 5 +++++ .../java/com/launchdarkly/android/LDClientInterface.java | 8 ++++++++ 2 files changed, 13 insertions(+) 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 6d015a71..65757d4f 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 @@ -590,6 +590,11 @@ public boolean isDisableBackgroundPolling() { return config.isDisableBackgroundPolling(); } + @Override + public String getVersion() { + return BuildConfig.VERSION_NAME; + } + static String getInstanceId() { return instanceId; } 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 6a33e93e..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 @@ -260,4 +260,12 @@ public interface LDClientInterface extends Closeable { * @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(); } From 723363d1ba04b712646e8e2601879e7d7cc30c95 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 8 Mar 2019 19:03:15 +0000 Subject: [PATCH 078/152] Check for null key before file comparison check. (#110) --- .../src/main/java/com/launchdarkly/android/Migration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 08554563..56c01d21 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java @@ -151,7 +151,7 @@ private static ArrayList getUserKeysPre_2_6(Application application, LDC continue; } for (String mobileKey : config.getMobileKeys().values()) { - if (name.contains(mobileKey)) { + if (mobileKey != null && name.contains(mobileKey)) { nameIter.remove(); break; } From 2e6a5bfa16a3d8789bccd31582359284e1c3a6c3 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 12 Mar 2019 20:23:57 +0000 Subject: [PATCH 079/152] [ch33658] Add unsafeReset() for LDClient testing re-initialization (#111) Add `unsafeReset()` method to close and clear instances for re-initializing client between tests. Update LDClientTest to call `unsafeReset()` before tests. --- .../launchdarkly/android/LDClientTest.java | 36 ++++++++++++++++++- .../com/launchdarkly/android/LDClient.java | 35 ++++++++++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) 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..3aa709cd 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,10 +12,12 @@ import org.junit.Test; import org.junit.runner.RunWith; +import java.io.IOException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotSame; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; @@ -35,7 +37,9 @@ public class LDClientTest { private LDUser ldUser; @Before - public void setUp() { + public void setUp() throws IOException { + LDClient.unsafeReset(); + ldConfig = new LDConfig.Builder() .setOffline(true) .build(); @@ -90,6 +94,7 @@ public void TestInitMissingApplication() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; + //noinspection ConstantConditions ldClientFuture = LDClient.init(null, ldConfig, ldUser); try { @@ -112,6 +117,7 @@ public void TestInitMissingConfig() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; + //noinspection ConstantConditions ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), null, ldUser); try { @@ -134,6 +140,7 @@ public void TestInitMissingUser() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; + //noinspection ConstantConditions ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, null); try { @@ -149,4 +156,31 @@ 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(); + } + + @UiThreadTest + @Test + public void testUnsafeReset() throws IOException { + ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); + LDClient.unsafeReset(); + + try { + LDClient.get(); + fail("Expected get() after unsafeReset() to throw"); + } catch (Exception ex) { + assertEquals(LaunchDarklyException.class, ex.getClass()); + } + + LDClient secondClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); + + assertNotSame(ldClient, secondClient); + secondClient.close(); + } } 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 65757d4f..5aced14f 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 @@ -20,10 +20,7 @@ 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.JsonPrimitive; import com.launchdarkly.android.flagstore.Flag; -import com.launchdarkly.android.gson.GsonCache; import java.io.Closeable; import java.io.IOException; @@ -68,6 +65,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; @@ -264,7 +262,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); @@ -492,8 +490,18 @@ public void close() 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 } } @@ -678,6 +686,21 @@ public SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return userManager.getSummaryEventSharedPreferences(); } + /** + * Thread unsafe reset method that closes all instances and removes the instances map. Visible + * just to allow re-initializing LDClient during testing. + */ + @VisibleForTesting + static void unsafeReset() throws IOException { + try { + if (instances != null) { + closeInstances(); + } + } finally { + instances = null; + } + } + UserManager getUserManager() { return userManager; } From 7ca817eaacbeafc983101979d6616e5069f9c99a Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 12 Mar 2019 23:44:19 +0000 Subject: [PATCH 080/152] [ch33846] Rename tests to not start with capitals and general refactoring (#112) * Rename tests to not start with capitals * Reindent MultiEnvironmentLDClientTest to be consistent * Optimize imports * Move TLS patch into TLSUtils * Make setModernTlsVersionsOnSocket private and remove redundant null check * Remove code duplication in LDClient track overloaded methods. * Remove validateParameter in LDClient that was using a NullPointerException as a null test. * Simplify Debounce to use listener instead of callback. --- .../launchdarkly/android/LDClientTest.java | 10 +- .../launchdarkly/android/LDConfigTest.java | 22 +- .../android/MultiEnvironmentLDClientTest.java | 245 +++++++++--------- .../launchdarkly/android/ThrottlerTest.java | 4 +- .../launchdarkly/android/UserHasherTest.java | 4 +- .../launchdarkly/android/UserManagerTest.java | 31 ++- .../android/flagstore/FlagTest.java | 1 - .../android/test/TestActivity.java | 2 +- .../com/launchdarkly/android/Debounce.java | 20 +- .../com/launchdarkly/android/LDClient.java | 49 +--- .../com/launchdarkly/android/UserManager.java | 1 - .../android/tls/ModernTLSSocketFactory.java | 4 +- .../launchdarkly/android/tls/TLSUtils.java | 25 ++ 13 files changed, 201 insertions(+), 217 deletions(-) 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 3aa709cd..cfebeb35 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 @@ -50,7 +50,7 @@ public void setUp() throws IOException { @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(); @@ -72,7 +72,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(); @@ -90,7 +90,7 @@ public void GivenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() { @UiThreadTest @Test - public void TestInitMissingApplication() { + public void testInitMissingApplication() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; @@ -113,7 +113,7 @@ public void TestInitMissingApplication() { @UiThreadTest @Test - public void TestInitMissingConfig() { + public void testInitMissingConfig() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; @@ -136,7 +136,7 @@ public void TestInitMissingConfig() { @UiThreadTest @Test - public void TestInitMissingUser() { + public void testInitMissingUser() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; 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 64b02fe1..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()); @@ -39,7 +39,7 @@ public void TestBuilderDefaults() { @Test - public void TestBuilderStreamDisabled() { + public void testBuilderStreamDisabled() { LDConfig config = new LDConfig.Builder() .setStream(false) .build(); @@ -52,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) @@ -67,7 +67,7 @@ public void TestBuilderStreamDisabledCustomIntervals() { } @Test - public void TestBuilderStreamDisabledBackgroundUpdatingDisabled() { + public void testBuilderStreamDisabledBackgroundUpdatingDisabled() { LDConfig config = new LDConfig.Builder() .setStream(false) .setDisableBackgroundUpdating(true) @@ -81,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) @@ -96,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) @@ -111,7 +111,7 @@ public void TestBuilderStreamDisabledBackgroundPollingIntervalBelowMinimum() { } @Test - public void TestBuilderUseReportDefaultGet() { + public void testBuilderUseReportDefaultGet() { LDConfig config = new LDConfig.Builder() .build(); @@ -119,7 +119,7 @@ public void TestBuilderUseReportDefaultGet() { } @Test - public void TestBuilderUseReporSetToGet() { + public void testBuilderUseReporSetToGet() { LDConfig config = new LDConfig.Builder() .setUseReport(false) .build(); @@ -128,7 +128,7 @@ public void TestBuilderUseReporSetToGet() { } @Test - public void TestBuilderUseReportSetToReport() { + public void testBuilderUseReportSetToReport() { LDConfig config = new LDConfig.Builder() .setUseReport(true) .build(); @@ -137,7 +137,7 @@ public void TestBuilderUseReportSetToReport() { } @Test - public void TestBuilderAllAttributesPrivate() { + public void testBuilderAllAttributesPrivate() { LDConfig config = new LDConfig.Builder() .build(); @@ -151,7 +151,7 @@ public void TestBuilderAllAttributesPrivate() { } @Test - public void TestBuilderPrivateAttributesList() { + public void testBuilderPrivateAttributesList() { LDConfig config = new LDConfig.Builder() .build(); 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 98d46212..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 @@ -5,7 +5,6 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import com.google.gson.Gson; import com.google.gson.JsonObject; import com.launchdarkly.android.flagstore.Flag; import com.launchdarkly.android.flagstore.FlagStore; @@ -56,7 +55,7 @@ public void before() { } @Test - public void TestFailedFetchThrowsException() throws InterruptedException { + public void testFailedFetchThrowsException() throws InterruptedException { setUserAndFailToFetchFlags("userKey"); } @@ -79,7 +78,7 @@ private void addSimpleFlag(JsonObject jsonObject, String flagKey, Number value) } @Test - public void TestBasicRetrieval() throws ExecutionException, InterruptedException { + public void testBasicRetrieval() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); @@ -96,7 +95,7 @@ public void TestBasicRetrieval() throws ExecutionException, InterruptedException } @Test - public void TestNewUserUpdatesFlags() { + public void testNewUserUpdatesFlags() { JsonObject flags = new JsonObject(); String flagKey = "stringFlag"; @@ -112,7 +111,7 @@ public void TestNewUserUpdatesFlags() { } @Test - public void TestCanStoreExactly5Users() throws InterruptedException { + public void testCanStoreExactly5Users() throws InterruptedException { JsonObject flags = new JsonObject(); String flagKey = "stringFlag"; @@ -136,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) { @@ -155,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) { @@ -172,7 +171,7 @@ public void onFeatureFlagChange(String flagKey) { } @Test - public void TestDeleteFlag() throws ExecutionException, InterruptedException { + public void testDeleteFlag() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); @@ -195,7 +194,7 @@ public void TestDeleteFlag() throws ExecutionException, InterruptedException { } @Test - public void TestDeleteForInvalidResponse() throws ExecutionException, InterruptedException { + public void testDeleteForInvalidResponse() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); @@ -214,7 +213,7 @@ public void TestDeleteForInvalidResponse() throws ExecutionException, Interrupte } @Test - public void TestDeleteWithVersion() throws ExecutionException, InterruptedException { + public void testDeleteWithVersion() throws ExecutionException, InterruptedException { Future future = setUserClear("userKey", new JsonObject()); future.get(); @@ -239,7 +238,7 @@ public void TestDeleteWithVersion() throws ExecutionException, InterruptedExcept } @Test - public void TestPatchForAddAndReplaceFlags() throws ExecutionException, InterruptedException { + public void testPatchForAddAndReplaceFlags() throws ExecutionException, InterruptedException { JsonObject jsonObject = new JsonObject(); addSimpleFlag(jsonObject, "boolFlag1", true); addSimpleFlag(jsonObject, "stringFlag1", "string1"); @@ -267,7 +266,7 @@ public void TestPatchForAddAndReplaceFlags() throws ExecutionException, Interrup } @Test - public void TestPatchSucceedsForMissingVersionInPatch() throws ExecutionException, InterruptedException { + public void testPatchSucceedsForMissingVersionInPatch() throws ExecutionException, InterruptedException { Future future = setUserClear("userKey", new JsonObject()); future.get(); @@ -329,7 +328,7 @@ public void TestPatchSucceedsForMissingVersionInPatch() throws ExecutionExceptio } @Test - public void TestPatchWithVersion() throws ExecutionException, InterruptedException { + public void testPatchWithVersion() throws ExecutionException, InterruptedException { Future future = setUserClear("userKey", new JsonObject()); future.get(); @@ -371,7 +370,7 @@ public void TestPatchWithVersion() throws ExecutionException, InterruptedExcepti } @Test - public void TestPatchForInvalidResponse() throws ExecutionException, InterruptedException { + public void testPatchForInvalidResponse() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); @@ -390,7 +389,7 @@ public void TestPatchForInvalidResponse() throws ExecutionException, Interrupted } @Test - public void TestPutForReplaceFlags() throws ExecutionException, InterruptedException { + public void testPutForReplaceFlags() throws ExecutionException, InterruptedException { JsonObject jsonObject = new JsonObject(); addSimpleFlag(jsonObject, "stringFlag1", "string1"); @@ -432,7 +431,7 @@ public void TestPutForReplaceFlags() throws ExecutionException, InterruptedExcep } @Test - public void TestPutForInvalidResponse() throws ExecutionException, InterruptedException { + public void testPutForInvalidResponse() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); 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 index 13d01004..08f8088d 100644 --- 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 @@ -6,7 +6,6 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.android.EvaluationReason; -import com.launchdarkly.android.flagstore.Flag; import com.launchdarkly.android.gson.GsonCache; import org.junit.Test; 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/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/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index 5aced14f..3cfe6b7b 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,12 +9,8 @@ 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.android.gms.security.ProviderInstaller; 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; @@ -25,7 +21,6 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -37,11 +32,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. @@ -86,17 +80,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")); } @@ -110,18 +101,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(); @@ -179,17 +159,6 @@ 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 @@ -314,11 +283,7 @@ public void track(String eventName, JsonElement data) { @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); } @Override 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 16607ca2..5d190b5f 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 @@ -24,7 +24,6 @@ import java.util.Collection; import java.util.List; -import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import timber.log.Timber; 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."); + } + } + } + } From 071bfb5edb0dcfc3fb7600752407f4c46432f5eb Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 13 Mar 2019 00:15:37 +0000 Subject: [PATCH 081/152] Add documentation for flagstore implementation (#113) --- .../android/flagstore/FlagInterface.java | 41 ++++++++++++++ .../android/flagstore/FlagStore.java | 56 +++++++++++++++++++ .../android/flagstore/FlagStoreFactory.java | 10 ++++ .../flagstore/FlagStoreFactoryInterface.java | 9 --- .../android/flagstore/FlagStoreManager.java | 34 +++++++++++ .../flagstore/FlagStoreUpdateType.java | 16 +++++- .../android/flagstore/FlagUpdate.java | 17 ++++++ .../flagstore/StoreUpdatedListener.java | 9 +++ .../SharedPrefsFlagStoreManager.java | 21 +++++-- .../android/response/DeleteFlagResponse.java | 7 +++ .../android/response/FlagsResponse.java | 10 ++++ 11 files changed, 215 insertions(+), 15 deletions(-) delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactoryInterface.java 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 index b7e95aef..4362d5e2 100644 --- 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 @@ -5,18 +5,59 @@ 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 index 3098c936..8122b6a5 100644 --- 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 @@ -4,26 +4,82 @@ 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 index 74e109e4..48fb3db0 100644 --- 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 @@ -2,8 +2,18 @@ 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/FlagStoreFactoryInterface.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactoryInterface.java deleted file mode 100644 index 61e54017..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactoryInterface.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.launchdarkly.android.flagstore; - -import android.support.annotation.NonNull; - -public interface FlagStoreFactoryInterface { - - 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 index 745588a2..371e0a93 100644 --- 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 @@ -4,15 +4,49 @@ 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 index bb3dde41..5885d06f 100644 --- 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 @@ -1,5 +1,19 @@ package com.launchdarkly.android.flagstore; +/** + * Types of updates that a FlagStore can report + */ public enum FlagStoreUpdateType { - FLAG_DELETED, FLAG_UPDATED, FLAG_CREATED + /** + * 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 index c218cc81..9855284c 100644 --- 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 @@ -1,9 +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 index cb0017a9..a4b7b212 100644 --- 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 @@ -1,5 +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/SharedPrefsFlagStoreManager.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java index 0a9b2d82..c5c12a2e 100644 --- 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 @@ -39,7 +39,9 @@ public class SharedPrefsFlagStoreManager implements FlagStoreManager, StoreUpdat private final SharedPreferences usersSharedPrefs; private final Multimap listeners; - public SharedPrefsFlagStoreManager(@NonNull Application application, @NonNull String mobileKey, @NonNull FlagStoreFactory flagStoreFactory) { + 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); @@ -55,6 +57,8 @@ public void switchToUser(String userKey) { 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(); @@ -62,13 +66,14 @@ public void switchToUser(String userKey) { 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(); - usersSharedPrefs.edit() - .remove(removed) - .apply(); + // Remove entry from usersSharedPrefs. + usersSharedPrefs.edit().remove(removed).apply(); } } } @@ -121,22 +126,28 @@ private Collection getAllUsers() { } private static String userAndTimeStampToHumanReadableString(String userSharedPrefsKey, Long timestamp) { - return userSharedPrefsKey + " [" + userSharedPrefsKey + "] timestamp: [" + timestamp + "] [" + new Date(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() { 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 index 065c9984..6125bdc4 100644 --- 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 @@ -13,6 +13,13 @@ public DeleteFlagResponse(String key, Integer version) { 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()) { 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 index 6e26de99..d49528b0 100644 --- 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 @@ -6,6 +6,11 @@ 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; @@ -14,6 +19,11 @@ 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; From 3777e9dc24c9dc083dbb05a8556d7abfc47727c4 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 28 Mar 2019 16:52:49 +0000 Subject: [PATCH 082/152] [ch35150] Unit tests and bug fixes (#114) - Use android test orchestrator to run tests isolated from each other. This prevents the issues testing singletons. Also enabled option to clear package data between runs allowing more extensive flagstore testing. - Remove unsafe reset as it was added only for allowing testing the LDClient singleton. - Tests for new FlagStore code. - Convenience test FlagBuilder - Fix Migration to not turn all flags into Strings - Fix issue with clearAndApplyFlagUpdates not generating correct events for listeners. --- launchdarkly-android-client/build.gradle | 13 +- .../android/DeleteFlagResponseTest.java | 54 +++ .../launchdarkly/android/LDClientTest.java | 24 +- .../launchdarkly/android/MigrationTest.java | 129 +++++++ .../android/flagstore/FlagBuilder.java | 62 ++++ .../flagstore/FlagStoreManagerTest.java | 273 ++++++++++++++ .../android/flagstore/FlagStoreTest.java | 344 ++++++++++++++++++ .../android/flagstore/FlagTest.java | 127 ++++++- .../SharedPrefsFlagStoreFactoryTest.java | 30 ++ .../SharedPrefsFlagStoreManagerTest.java | 34 ++ .../sharedprefs/SharedPrefsFlagStoreTest.java | 157 ++++---- .../com/launchdarkly/android/LDClient.java | 15 - .../com/launchdarkly/android/Migration.java | 58 +-- .../com/launchdarkly/android/UserManager.java | 1 - .../sharedprefs/SharedPrefsFlagStore.java | 48 ++- 15 files changed, 1204 insertions(+), 165 deletions(-) create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/DeleteFlagResponseTest.java create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MigrationTest.java create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagBuilder.java create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreManagerTest.java create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreTest.java create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactoryTest.java create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManagerTest.java diff --git a/launchdarkly-android-client/build.gradle b/launchdarkly-android-client/build.gradle index 1ecbdaa9..0abc566e 100644 --- a/launchdarkly-android-client/build.gradle +++ b/launchdarkly-android-client/build.gradle @@ -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 @@ -60,7 +70,7 @@ ext { 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/LDClientTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDClientTest.java index cfebeb35..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 @@ -17,7 +17,6 @@ import java.util.concurrent.Future; import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertNotSame; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; @@ -37,9 +36,7 @@ public class LDClientTest { private LDUser ldUser; @Before - public void setUp() throws IOException { - LDClient.unsafeReset(); - + public void setUp() { ldConfig = new LDConfig.Builder() .setOffline(true) .build(); @@ -164,23 +161,4 @@ public void testDoubleClose() throws IOException { ldClient.close(); ldClient.close(); } - - @UiThreadTest - @Test - public void testUnsafeReset() throws IOException { - ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); - LDClient.unsafeReset(); - - try { - LDClient.get(); - fail("Expected get() after unsafeReset() to throw"); - } catch (Exception ex) { - assertEquals(LaunchDarklyException.class, ex.getClass()); - } - - LDClient secondClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); - - assertNotSame(ldClient, secondClient); - secondClient.close(); - } } 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/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 index 08f8088d..6d7fcd28 100644 --- 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 @@ -3,6 +3,8 @@ 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; @@ -10,6 +12,8 @@ import org.junit.Test; +import java.util.Arrays; +import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; @@ -31,10 +35,43 @@ public class FlagTest { .build(); @Test - public void valueIsSerialized() { - final Flag r = new Flag("flag", new JsonPrimitive("yes"), null, null, null, null, null, null); + public void keyIsSerialized() { + final Flag r = new FlagBuilder("flag").build(); final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); - assertEquals(new JsonPrimitive("yes"), json.get("value")); + 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 @@ -44,9 +81,16 @@ public void valueIsDeserialized() { 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 Flag("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + final Flag r = new FlagBuilder("flag").version(99).build(); final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(99), json.get("version")); } @@ -59,9 +103,16 @@ 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 Flag("flag", new JsonPrimitive("yes"), 99, 100, null, null, null, null); + final Flag r = new FlagBuilder("flag").flagVersion(100).build(); final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(100), json.get("flagVersion")); } @@ -83,7 +134,7 @@ public void flagVersionDefaultWhenOmitted() { @Test public void variationIsSerialized() { - final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, null, null, null); + final Flag r = new FlagBuilder("flag").variation(2).build(); final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(2), json.get("variation")); } @@ -104,7 +155,7 @@ public void variationDefaultWhenOmitted() { @Test public void trackEventsIsSerialized() { - final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, true, null, null); + final Flag r = new FlagBuilder("flag").trackEvents(true).build(); final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(new JsonPrimitive(true), json.get("trackEvents")); } @@ -126,9 +177,15 @@ public void trackEventsDefaultWhenOmitted() { @Test public void debugEventsUntilDateIsSerialized() { final long date = 12345L; - final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, 2, false, date, null); + 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 @@ -136,6 +193,11 @@ 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 @@ -151,7 +213,7 @@ public void reasonIsSerialized() { final EvaluationReason reason = e.getKey(); final String expectedJsonStr = e.getValue(); final JsonObject expectedJson = gson.fromJson(expectedJsonStr, JsonObject.class); - final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, reason); + final Flag r = new FlagBuilder("flag").reason(reason).build(); final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); assertEquals(expectedJson, json.get("reason")); } @@ -179,8 +241,53 @@ public void reasonDefaultWhenOmitted() { @Test public void emptyPropertiesAreNotSerialized() { - final Flag r = new Flag("flag", new JsonPrimitive("yes"), 99, 100, null, false, null, null); + 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 index e8d8d7bf..050fec7f 100644 --- 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 @@ -1,16 +1,20 @@ package com.launchdarkly.android.flagstore.sharedprefs; +import android.app.Application; import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; -import com.google.gson.JsonPrimitive; 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; @@ -18,32 +22,44 @@ 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 { +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 Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); - final Flag key2 = new Flag("key2", new JsonPrimitive(true), null, null, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").version(12).build(); + final Flag key2 = new FlagBuilder("key2").version(null).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); - Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 12, 0); - Assert.assertEquals(flagStore.getFlag(key2.getKey()).getVersion(), null); + assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 12, 0); + assertNull(flagStore.getFlag(key2.getKey()).getVersion()); } @Test public void deletesVersions() { - final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").version(12).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Collections.singletonList(key1)); flagStore.applyFlagUpdate(new DeleteFlagResponse(key1.getKey(), null)); @@ -52,100 +68,89 @@ public void deletesVersions() { @Test public void updatesVersions() { - final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); - final Flag updatedKey1 = new Flag(key1.getKey(), key1.getValue(), 15, null, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").version(12).build(); + final Flag updatedKey1 = new FlagBuilder(key1.getKey()).version(15).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Collections.singletonList(key1)); flagStore.applyFlagUpdate(updatedKey1); - Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 15, 0); + assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 15, 0); } @Test public void clearsFlags() { - final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, null, null, null, null); - final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").version(12).build(); + final Flag key2 = new FlagBuilder("key2").version(14).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + 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())); - Assert.assertEquals(0, flagStore.getAllFlags().size(), 0); + assertEquals(0, flagStore.getAllFlags().size(), 0); } @Test public void savesVariation() { - final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, null, null, null); - final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, null, null, null); - final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").variation(16).build(); + final Flag key2 = new FlagBuilder("key2").variation(null).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); - flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); - Assert.assertEquals(flagStore.getFlag(key1.getKey()).getVariation(), 16, 0); - Assert.assertEquals(flagStore.getFlag(key2.getKey()).getVariation(), 23, 0); - Assert.assertEquals(flagStore.getFlag(key3.getKey()).getVariation(), null); + assertEquals(flagStore.getFlag(key1.getKey()).getVariation(), 16, 0); + assertNull(flagStore.getFlag(key2.getKey()).getVariation()); } @Test public void savesTrackEvents() { - final Flag key1 = new Flag("key1", new JsonPrimitive(true), 12, null, 16, false, 123456789L, null); - final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, true, 987654321L, null); - final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + 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(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); - Assert.assertEquals(flagStore.getFlag(key1.getKey()).getTrackEvents(), false); - Assert.assertEquals(flagStore.getFlag(key2.getKey()).getTrackEvents(), true); + 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 Flag("key1", new JsonPrimitive(true), 12, null, 16, false, 123456789L, null); - final Flag key2 = new Flag("key2", new JsonPrimitive(true), 14, null, 23, true, 987654321L, null); - final Flag key3 = new Flag("key3", new JsonPrimitive(true), 16, null, null, null, null, null); + 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(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); - //noinspection ConstantConditions - Assert.assertEquals(flagStore.getFlag(key1.getKey()).getDebugEventsUntilDate(), 123456789L, 0); - //noinspection ConstantConditions - Assert.assertEquals(flagStore.getFlag(key2.getKey()).getDebugEventsUntilDate(), 987654321L, 0); + 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 Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); - final Flag key2 = new Flag("key2", new JsonPrimitive(true), null, null, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); + final Flag key2 = new FlagBuilder("key2").flagVersion(null).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); - Assert.assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 12, 0); - Assert.assertEquals(flagStore.getFlag(key2.getKey()).getFlagVersion(), null); + assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 12, 0); + assertNull(flagStore.getFlag(key2.getKey()).getFlagVersion()); } @Test public void deletesFlagVersions() { - final Flag key1 = new Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Collections.singletonList(key1)); flagStore.applyFlagUpdate(new DeleteFlagResponse(key1.getKey(), null)); @@ -154,47 +159,45 @@ public void deletesFlagVersions() { @Test public void updatesFlagVersions() { - final Flag key1 = new Flag("key1", new JsonPrimitive(true), null, 12, null, null, null, null); - final Flag updatedKey1 = new Flag(key1.getKey(), key1.getValue(), null, 15, null, null, null, null); + final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); + final Flag updatedKey1 = new FlagBuilder(key1.getKey()).flagVersion(15).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Collections.singletonList(key1)); flagStore.applyFlagUpdate(updatedKey1); - Assert.assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 15, 0); + assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 15, 0); } @Test public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() { - final Flag withFlagVersion = new Flag("withFlagVersion", new JsonPrimitive(true), 12, 13, null, null, null, null); - final Flag withOnlyVersion = new Flag("withOnlyVersion", new JsonPrimitive(true), 12, null, null, null, null, null); + final Flag withFlagVersion = + new FlagBuilder("withFlagVersion").version(12).flagVersion(13).build(); + final Flag withOnlyVersion = new FlagBuilder("withOnlyVersion").version(12).build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Arrays.asList(withFlagVersion, withOnlyVersion)); - Assert.assertEquals(flagStore.getFlag(withFlagVersion.getKey()).getVersionForEvents(), 13, 0); - Assert.assertEquals(flagStore.getFlag(withOnlyVersion.getKey()).getVersionForEvents(), 12, 0); + 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 + // 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 Flag("key1", new JsonPrimitive(true), 11, - 1, 1, null, null, reason); - final Flag flag2 = new Flag("key2", new JsonPrimitive(true), 11, - 1, 1, null, null, null); + final Flag flag1 = new FlagBuilder("key1").reason(reason).build(); + final Flag flag2 = new FlagBuilder("key2").build(); - SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(activityTestRule.getActivity().getApplication(), "abc"); - flagStore.clear(); + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); flagStore.applyFlagUpdates(Arrays.asList(flag1, flag2)); - Assert.assertEquals(reason, flagStore.getFlag(flag1.getKey()).getReason()); - Assert.assertNull(flagStore.getFlag(flag2.getKey()).getReason()); + 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/main/java/com/launchdarkly/android/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index 3cfe6b7b..3e09e14e 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 @@ -651,21 +651,6 @@ public SummaryEventSharedPreferences getSummaryEventSharedPreferences() { return userManager.getSummaryEventSharedPreferences(); } - /** - * Thread unsafe reset method that closes all instances and removes the instances map. Visible - * just to allow re-initializing LDClient during testing. - */ - @VisibleForTesting - static void unsafeReset() throws IOException { - try { - if (instances != null) { - closeInstances(); - } - } finally { - instances = null; - } - } - UserManager getUserManager() { return userManager; } 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 index 56c01d21..3b81bd21 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java @@ -6,7 +6,9 @@ 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; @@ -35,6 +37,25 @@ static void migrateWhenNeeded(Application application, LDConfig config) { } } + 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"); @@ -42,7 +63,6 @@ private static void migrate_2_7_fresh(Application application, LDConfig config) SharedPreferences versionSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "version", Context.MODE_PRIVATE); Map flagData = versionSharedPrefs.getAll(); Set flagKeys = flagData.keySet(); - JsonObject jsonFlags = GsonCache.getGson().toJsonTree(flagData).getAsJsonObject(); boolean allSuccess = true; for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { @@ -52,13 +72,11 @@ private static void migrate_2_7_fresh(Application application, LDConfig config) boolean stores = true; for (String key : userKeys) { Map flagValues = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + key, Context.MODE_PRIVATE).getAll(); - SharedPreferences.Editor userFlagStoreEditor = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-flags", Context.MODE_PRIVATE).edit(); + String prefsKey = LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-flags"; + SharedPreferences.Editor userFlagStoreEditor = application.getSharedPreferences(prefsKey, Context.MODE_PRIVATE).edit(); for (String flagKey : flagKeys) { - Object value = flagValues.get(flagKey); - JsonObject flagJson = jsonFlags.get(flagKey).getAsJsonObject(); - flagJson.addProperty("key", flagKey); - flagJson.addProperty("value", GsonCache.getGson().toJson(value)); - userFlagStoreEditor.putString(flagKey, GsonCache.getGson().toJson(flagJson)); + String flagString = reconstructFlag(flagKey, (String) flagData.get(flagKey), flagValues.get(flagKey)); + userFlagStoreEditor.putString(flagKey, flagString); } stores = stores && userFlagStoreEditor.commit(); } @@ -82,7 +100,7 @@ private static void migrate_2_7_fresh(Application application, LDConfig config) } private static void migrate_2_7_from_2_6(Application application) { - Timber.d("Migrating to v2.7.0 shared preferences store"); + Timber.d("Migrating to v2.7.0 shared preferences store from v2.6.0"); Multimap keyUsers = getUserKeys_2_6(application); @@ -91,21 +109,13 @@ private static void migrate_2_7_from_2_6(Application application) { SharedPreferences versionSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-version", Context.MODE_PRIVATE); Map flagData = versionSharedPrefs.getAll(); Set flagKeys = flagData.keySet(); - JsonObject jsonFlags = new JsonObject(); - for (Map.Entry flagDataEntry : flagData.entrySet()) { - JsonObject jsonVal = GsonCache.getGson().fromJson((String)flagDataEntry.getValue(), JsonObject.class); - jsonFlags.add(flagDataEntry.getKey(), jsonVal); - } 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) { - Object value = flagValues.get(flagKey); - JsonObject flagJson = jsonFlags.get(flagKey).getAsJsonObject(); - flagJson.addProperty("key", flagKey); - flagJson.addProperty("value", GsonCache.getGson().toJson(value)); - userFlagStoreEditor.putString(flagKey, GsonCache.getGson().toJson(flagJson)); + String flagString = reconstructFlag(flagKey, (String) flagData.get(flagKey), flagValues.get(flagKey)); + userFlagStoreEditor.putString(flagKey, flagString); } allSuccess = allSuccess && userFlagStoreEditor.commit(); } @@ -127,7 +137,7 @@ private static void migrate_2_7_from_2_6(Application application) { } } - private static ArrayList getUserKeysPre_2_6(Application application, LDConfig config) { + 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<>(); @@ -165,7 +175,7 @@ private static ArrayList getUserKeysPre_2_6(Application application, LDC return userKeys; } - private static Multimap getUserKeys_2_6(Application application) { + static Multimap getUserKeys_2_6(Application application) { File directory = new File(application.getFilesDir().getParent() + "/shared_prefs/"); File[] files = directory.listFiles(); ArrayList filenames = new ArrayList<>(); @@ -180,9 +190,11 @@ private static Multimap getUserKeys_2_6(Application application) for (String filename : filenames) { String strip = filename.substring(LDConfig.SHARED_PREFS_BASE_KEY.length(), filename.length() - 9); int splitAt = strip.length() - 44; - String mobileKey = strip.substring(0, splitAt); - String userKey = strip.substring(splitAt); - keyUserMap.put(mobileKey, userKey); + if (splitAt > 0) { + String mobileKey = strip.substring(0, splitAt); + String userKey = strip.substring(splitAt); + keyUserMap.put(mobileKey, userKey); + } } return keyUserMap; } 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 5d190b5f..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 @@ -101,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()); 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 index e7de7ca2..70d3bbd5 100644 --- 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 @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import javax.annotation.Nullable; @@ -76,6 +77,9 @@ public Flag getFlag(String flagKey) { 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) { @@ -104,18 +108,6 @@ public void applyFlagUpdate(FlagUpdate flagUpdate) { } } - @NonNull - private ArrayList> applyFlagUpdatesNoCommit(SharedPreferences.Editor editor, List flagUpdates) { - ArrayList> updates = new ArrayList<>(); - for (FlagUpdate flagUpdate : flagUpdates) { - Pair update = applyFlagUpdateNoCommit(editor, flagUpdate); - if (update != null) { - updates.add(update); - } - } - return updates; - } - private void informListenersOfUpdateList(List> updates) { StoreUpdatedListener storeUpdatedListener = listenerWeakReference.get(); if (storeUpdatedListener != null) { @@ -128,15 +120,41 @@ private void informListenersOfUpdateList(List> @Override public void applyFlagUpdates(List flagUpdates) { SharedPreferences.Editor editor = sharedPreferences.edit(); - ArrayList> updates = applyFlagUpdatesNoCommit(editor, flagUpdates); + 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) { - sharedPreferences.edit().clear().apply(); - applyFlagUpdates(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 From 77eff6f6fb95fbadbbfef56bd4df177c3730b3b0 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 28 Mar 2019 19:34:30 +0000 Subject: [PATCH 083/152] Add compatibility behavior to stringVariation and allFlags methods. (#115) If a Json flag is requested with stringVariation it will serialize it to a String. Json flags will also be serialized to Strings for the map returned by allFlags() --- .../com/launchdarkly/android/LDClient.java | 15 ++++++++--- .../com/launchdarkly/android/ValueTypes.java | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) 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 3e09e14e..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 @@ -17,6 +17,7 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonElement; import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.gson.GsonCache; import java.io.Closeable; import java.io.IOException; @@ -340,7 +341,8 @@ public Void apply(List input) { for (Flag flag : flags) { JsonElement jsonVal = flag.getValue(); if (jsonVal == null || jsonVal.isJsonNull()) { - result.put(flag.getKey(), null); + // 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()) { @@ -348,7 +350,10 @@ public Void apply(List input) { } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { result.put(flag.getKey(), jsonVal.getAsString()); } else { - result.put(flag.getKey(), jsonVal); + // 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; @@ -386,12 +391,14 @@ public EvaluationDetail floatVariationDetail(String flagKey, Float fallba @Override public String stringVariation(String flagKey, String fallback) { - return variationDetailInternal(flagKey, fallback, ValueTypes.STRING, false).getValue(); + // 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) { - return variationDetailInternal(flagKey, fallback, ValueTypes.STRING, true); + // TODO(gwhelanld): Change to ValueTypes.String in 3.0.0 + return variationDetailInternal(flagKey, fallback, ValueTypes.STRINGCOMPAT, true); } @Override 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 index 7ba922c2..fb4c06b1 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ValueTypes.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ValueTypes.java @@ -5,6 +5,9 @@ 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. @@ -78,6 +81,28 @@ public JsonElement valueToJson(String 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) { From 26ac3eeafdec69aaaed54bf2967b1c06101059ee Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 11 Apr 2019 23:14:04 +0000 Subject: [PATCH 084/152] Update LDUser not to store all fields as Json. (#116) Add testing rule to setup and teardown Timber trees for debug logging. Add additional LDUser tests. Fixed a bit of flakiness in deletesOlderThanLastFiveStoredUsers test that showed up all of a sudden. --- .../android/DeleteFlagResponseTest.java | 4 + .../android/EvaluationReasonTest.java | 5 + .../com/launchdarkly/android/EventTest.java | 121 +++--- .../launchdarkly/android/LDClientTest.java | 3 + .../launchdarkly/android/LDConfigTest.java | 9 +- .../com/launchdarkly/android/LDUserTest.java | 222 +++++++++- .../launchdarkly/android/MigrationTest.java | 13 +- .../android/MultiEnvironmentLDClientTest.java | 4 + .../launchdarkly/android/ThrottlerTest.java | 3 + .../android/TimberLoggingRule.java | 18 + .../launchdarkly/android/UserHasherTest.java | 4 + .../launchdarkly/android/UserManagerTest.java | 3 + ...UserSummaryEventSharedPreferencesTest.java | 3 + .../flagstore/FlagStoreManagerTest.java | 45 +- .../android/flagstore/FlagTest.java | 5 + .../SharedPrefsFlagStoreFactoryTest.java | 4 + .../SharedPrefsFlagStoreManagerTest.java | 4 + .../sharedprefs/SharedPrefsFlagStoreTest.java | 4 + .../java/com/launchdarkly/android/Event.java | 2 +- .../com/launchdarkly/android/LDClient.java | 6 +- .../java/com/launchdarkly/android/LDUser.java | 407 ++++++++---------- .../com/launchdarkly/android/Migration.java | 3 + .../launchdarkly/android/UserAttribute.java | 65 --- 23 files changed, 566 insertions(+), 391 deletions(-) create mode 100644 launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/TimberLoggingRule.java delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserAttribute.java 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 index 880a549d..2ab61f1c 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/DeleteFlagResponseTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/DeleteFlagResponseTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.android.gson.GsonCache; import com.launchdarkly.android.response.DeleteFlagResponse; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -17,6 +18,9 @@ @RunWith(AndroidJUnit4.class) public class DeleteFlagResponseTest { + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + private static final Gson gson = GsonCache.getGson(); @Test 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 index 977f1919..02ae6bd9 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java @@ -3,13 +3,18 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; +import org.junit.Rule; import org.junit.Test; import static org.junit.Assert.assertEquals; public class EvaluationReasonTest { + private static final Gson gson = new LDConfig.Builder().build().getFilteredEventGson(); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Test public void testOffReasonSerialization() { EvaluationReason reason = EvaluationReason.off(); 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 db1df4d8..a04d6342 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 @@ -3,18 +3,26 @@ import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; +import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +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.HashSet; +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; + /** * Created by Farhan on 2018-01-04. */ @@ -25,6 +33,9 @@ public class EventTest { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Test public void testPrivateAttributesAreConcatenated() { @@ -51,15 +62,15 @@ public void testPrivateAttributesAreConcatenated() { JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(event); JsonArray privateAttrs = jsonElement.getAsJsonObject().get("user").getAsJsonObject().getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); - Assert.assertNotNull(jsonElement); - Assert.assertNotNull(privateAttrs); - Assert.assertEquals(privateAttrs.getAsJsonArray().size(), 4); + assertNotNull(jsonElement); + assertNotNull(privateAttrs); + assertEquals(privateAttrs.getAsJsonArray().size(), 4); - Assert.assertTrue(privateAttrs.toString().contains(LDUser.AVATAR)); - Assert.assertTrue(privateAttrs.toString().contains("privateValue1")); - Assert.assertTrue(privateAttrs.toString().contains(LDUser.EMAIL)); - Assert.assertTrue(privateAttrs.toString().contains("Value2")); - Assert.assertFalse(privateAttrs.toString().contains(LDUser.LAST_NAME)); + assertTrue(privateAttrs.toString().contains(LDUser.AVATAR)); + assertTrue(privateAttrs.toString().contains("privateValue1")); + assertTrue(privateAttrs.toString().contains(LDUser.EMAIL)); + assertTrue(privateAttrs.toString().contains("Value2")); + assertFalse(privateAttrs.toString().contains(LDUser.LAST_NAME)); } @Test @@ -78,8 +89,8 @@ public void testPrivateAttributes() { JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(event); - Assert.assertTrue(jsonElement.toString().contains("email@server.net")); - Assert.assertFalse(jsonElement.toString().contains("privateAvatar")); + assertTrue(jsonElement.toString().contains("email@server.net")); + assertFalse(jsonElement.toString().contains("privateAvatar")); } @Test @@ -104,10 +115,10 @@ public void testRegularAttributesAreFilteredWithPrivateAttributes() { JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(event); JsonArray privateAttrs = jsonElement.getAsJsonObject().get("user").getAsJsonObject().getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); - Assert.assertNotNull(jsonElement); - Assert.assertTrue(user.getAvatar().getAsString().equals("avatarValue")); - Assert.assertTrue(privateAttrs.toString().contains(LDUser.AVATAR)); - Assert.assertFalse(jsonElement.toString().contains("avatarValue")); + assertNotNull(jsonElement); + assertEquals("avatarValue", user.getAvatar()); + assertTrue(privateAttrs.toString().contains(LDUser.AVATAR)); + assertFalse(jsonElement.toString().contains("avatarValue")); } @@ -126,9 +137,13 @@ public void testPrivateAttributesJsonOnLDUserObject() { JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(event); - Assert.assertNotNull(user); - Assert.assertFalse(user.getJson().contains("\"privateAttrs\":[\"avatar\",\"email\"]")); - Assert.assertTrue(jsonElement.toString().contains("\"privateAttrs\":[\"avatar\",\"email\"]")); + assertNotNull(user); + JsonObject userEval = (new Gson()).fromJson(user.getJson(), JsonObject.class); + assertFalse(userEval.has("privateAttrs")); + JsonArray privateAttrs = jsonElement.getAsJsonObject().getAsJsonObject("user").getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); + assertEquals(2, privateAttrs.size()); + assertTrue(privateAttrs.contains(new JsonPrimitive(LDUser.AVATAR))); + assertTrue(privateAttrs.contains(new JsonPrimitive(LDUser.EMAIL))); } @Test @@ -147,16 +162,16 @@ public void testRegularAttributesAreFilteredWithAllAttributesPrivate() { final Event event = new GenericEvent("kind1", "key1", user); JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(event); - JsonArray privateAttrs = jsonElement.getAsJsonObject().get("user").getAsJsonObject().getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); + JsonArray privateAttrs = jsonElement.getAsJsonObject().getAsJsonObject("user").getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); - Assert.assertNotNull(user); - Assert.assertNotNull(jsonElement); - Assert.assertNotNull(privateAttrs); - Assert.assertEquals(privateAttrs.getAsJsonArray().size(), 3); + assertNotNull(user); + assertNotNull(jsonElement); + assertNotNull(privateAttrs); + assertEquals(3, privateAttrs.size()); - Assert.assertTrue(privateAttrs.toString().contains(LDUser.AVATAR)); - Assert.assertTrue(privateAttrs.toString().contains(LDUser.EMAIL)); - Assert.assertTrue(privateAttrs.toString().contains("value1")); + assertTrue(privateAttrs.contains(new JsonPrimitive(LDUser.AVATAR))); + assertTrue(privateAttrs.contains(new JsonPrimitive(LDUser.EMAIL))); + assertTrue(privateAttrs.contains(new JsonPrimitive("value1"))); } @Test @@ -174,15 +189,15 @@ public void testKeyAndAnonymousAreNotFilteredWithAllAttributesPrivate() { final Event event = new GenericEvent("kind1", "key1", user); JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(event); - JsonArray privateAttrs = jsonElement.getAsJsonObject().get("user").getAsJsonObject().getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); + JsonArray privateAttrs = jsonElement.getAsJsonObject().getAsJsonObject("user").getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); - Assert.assertNotNull(user); - Assert.assertNotNull(jsonElement); - Assert.assertNotNull(privateAttrs); - Assert.assertEquals(privateAttrs.getAsJsonArray().size(), 1); + assertNotNull(user); + assertNotNull(jsonElement); + assertNotNull(privateAttrs); + assertEquals(1, privateAttrs.getAsJsonArray().size()); - Assert.assertTrue(jsonElement.toString().contains("key")); - Assert.assertTrue(jsonElement.toString().contains("anonymous")); + assertTrue(jsonElement.toString().contains("key")); + assertTrue(jsonElement.toString().contains("anonymous")); } @Test @@ -192,10 +207,10 @@ public void testUserObjectRemovedFromFeatureEvent() { LDUser user = builder.build(); - final FeatureRequestEvent event = new FeatureRequestEvent("key1", user.getKeyAsString(), JsonNull.INSTANCE, JsonNull.INSTANCE, -1, -1, null); + final FeatureRequestEvent event = new FeatureRequestEvent("key1", user.getKey(), JsonNull.INSTANCE, JsonNull.INSTANCE, -1, -1, null); - Assert.assertNull(event.user); - Assert.assertEquals(user.getKeyAsString(), event.userKey); + assertNull(event.user); + assertEquals(user.getKey(), event.userKey); } @Test @@ -207,8 +222,8 @@ public void testFullUserObjectIncludedInFeatureEvent() { final FeatureRequestEvent event = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, -1, -1, null); - Assert.assertEquals(user, event.user); - Assert.assertNull(event.userKey); + assertEquals(user, event.user); + assertNull(event.userKey); } @Test @@ -218,10 +233,10 @@ public void testUserObjectRemovedFromCustomEvent() { LDUser user = builder.build(); - final CustomEvent event = new CustomEvent("key1", user.getKeyAsString(), null); + final CustomEvent event = new CustomEvent("key1", user.getKey(), null); - Assert.assertNull(event.user); - Assert.assertEquals(user.getKeyAsString(), event.userKey); + assertNull(event.user); + assertEquals(user.getKey(), event.userKey); } @Test @@ -233,8 +248,8 @@ public void testFullUserObjectIncludedInCustomEvent() { final CustomEvent event = new CustomEvent("key1", user, null); - Assert.assertEquals(user, event.user); - Assert.assertNull(event.userKey); + assertEquals(user, event.user); + assertNull(event.userKey); } @Test @@ -250,17 +265,17 @@ public void testOptionalFieldsAreExcludedAppropriately() { 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); + assertEquals(5, hasVersionEvent.version, 0.0f); + assertNull(hasVersionEvent.variation); + assertNull(hasVersionEvent.reason); - Assert.assertEquals(20, hasVariationEvent.variation, 0); - Assert.assertNull(hasVariationEvent.version); - Assert.assertNull(hasVariationEvent.reason); + assertEquals(20, hasVariationEvent.variation, 0); + assertNull(hasVariationEvent.version); + assertNull(hasVariationEvent.reason); - Assert.assertEquals(5, hasReasonEvent.version, 0); - Assert.assertEquals(20, hasReasonEvent.variation, 0); - Assert.assertEquals(reason, hasReasonEvent.reason); + assertEquals(5, hasReasonEvent.version, 0); + assertEquals(20, hasReasonEvent.variation, 0); + assertEquals(reason, hasReasonEvent.reason); } @Test @@ -277,6 +292,6 @@ public void reasonIsSerialized() { JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(hasReasonEvent); JsonElement expected = config.getFilteredEventGson().fromJson("{\"kind\":\"FALLTHROUGH\"}", JsonElement.class); - Assert.assertEquals(expected, jsonElement.getAsJsonObject().get("reason")); + 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 2fe273b4..f2cd51d9 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 @@ -30,6 +30,9 @@ public class LDClientTest { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + private LDClient ldClient; private Future ldClientFuture; private LDConfig ldConfig; 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 14a1ef8c..bdf6cd1d 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 @@ -2,6 +2,7 @@ import android.support.test.runner.AndroidJUnit4; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -9,11 +10,15 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) public class LDConfigTest { + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Test public void testBuilderDefaults() { LDConfig config = new LDConfig.Builder().build(); @@ -30,9 +35,9 @@ public void testBuilderDefaults() { assertEquals(LDConfig.DEFAULT_POLLING_INTERVAL_MILLIS, config.getPollingIntervalMillis()); assertEquals(LDConfig.DEFAULT_BACKGROUND_POLLING_INTERVAL_MILLIS, config.getBackgroundPollingIntervalMillis()); - assertEquals(false, config.isDisableBackgroundPolling()); + assertFalse(config.isDisableBackgroundPolling()); - assertEquals(null, config.getMobileKey()); + assertNull(config.getMobileKey()); assertFalse(config.inlineUsersInEvents()); assertFalse(config.isEvaluationReasons()); } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDUserTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDUserTest.java index afb23e26..9e86c890 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDUserTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDUserTest.java @@ -2,18 +2,202 @@ import android.support.test.runner.AndroidJUnit4; -import org.junit.Assert; +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 org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.Arrays; import java.util.Set; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + /** * Created by Farhan on 2018-01-02. */ @RunWith(AndroidJUnit4.class) public class LDUserTest { + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + + @Test + public void testBasicFields() { + LDUser.Builder builder = new LDUser.Builder("a") + .anonymous(true) + .avatar("theAvatar") + .country("US") + .email("foo@mail.co") + .firstName("tester") + .lastName("one") + .name("tester one") + .ip("1.1.1.1") + .secondary("b") + .custom("ckeystring", "cvaluestring") + .custom("ckeynum", 7.3) + .custom("ckeybool", false) + .customNumber("ckeynumlist", Arrays.asList(1, 2, 3)) + .customString("ckeystringlist", Arrays.asList("abc", "def")); + + LDUser ldUser = builder.build(); + assertEquals("a", ldUser.getKey()); + assertTrue(ldUser.getAnonymous()); + assertEquals("theAvatar", ldUser.getAvatar()); + assertEquals("US", ldUser.getCountry()); + assertEquals("foo@mail.co", ldUser.getEmail()); + assertEquals("tester", ldUser.getFirstName()); + assertEquals("one", ldUser.getLastName()); + assertEquals("tester one", ldUser.getName()); + assertEquals("1.1.1.1", ldUser.getIp()); + assertEquals("b", ldUser.getSecondary()); + assertEquals(new JsonPrimitive("cvaluestring"), ldUser.getCustom("ckeystring")); + assertEquals(new JsonPrimitive(7.3), ldUser.getCustom("ckeynum")); + assertEquals(new JsonPrimitive(false), ldUser.getCustom("ckeybool")); + assertEquals(3, ldUser.getCustom("ckeynumlist").getAsJsonArray().size()); + assertTrue(ldUser.getCustom("ckeynumlist").getAsJsonArray().contains(new JsonPrimitive(1))); + assertTrue(ldUser.getCustom("ckeynumlist").getAsJsonArray().contains(new JsonPrimitive(2))); + assertTrue(ldUser.getCustom("ckeynumlist").getAsJsonArray().contains(new JsonPrimitive(3))); + assertEquals(2, ldUser.getCustom("ckeystringlist").getAsJsonArray().size()); + assertTrue(ldUser.getCustom("ckeystringlist").getAsJsonArray().contains(new JsonPrimitive("abc"))); + assertTrue(ldUser.getCustom("ckeystringlist").getAsJsonArray().contains(new JsonPrimitive("def"))); + + assertEquals(0, ldUser.getPrivateAttributeNames().size()); + + JsonObject jsonUser = (new Gson()).fromJson(ldUser.getJson(), JsonObject.class); + assertEquals("a", jsonUser.getAsJsonPrimitive("key").getAsString()); + assertTrue(jsonUser.getAsJsonPrimitive("anonymous").getAsBoolean()); + assertEquals("theAvatar", jsonUser.getAsJsonPrimitive(LDUser.AVATAR).getAsString()); + assertEquals("US", jsonUser.get(LDUser.COUNTRY).getAsString()); + assertEquals("foo@mail.co", jsonUser.getAsJsonPrimitive(LDUser.EMAIL).getAsString()); + assertEquals("tester", jsonUser.getAsJsonPrimitive(LDUser.FIRST_NAME).getAsString()); + assertEquals("one", jsonUser.getAsJsonPrimitive(LDUser.LAST_NAME).getAsString()); + assertEquals("tester one", jsonUser.getAsJsonPrimitive(LDUser.NAME).getAsString()); + assertEquals("1.1.1.1", jsonUser.getAsJsonPrimitive(LDUser.IP).getAsString()); + assertEquals("b", jsonUser.getAsJsonPrimitive(LDUser.SECONDARY).getAsString()); + + LDConfig ldConfig = new LDConfig.Builder().build(); + JsonObject eventJson = ldConfig.getFilteredEventGson().toJsonTree(ldUser).getAsJsonObject(); + assertEquals(11, eventJson.size()); + assertEquals("a", eventJson.getAsJsonPrimitive("key").getAsString()); + assertTrue(eventJson.getAsJsonPrimitive("anonymous").getAsBoolean()); + assertEquals("theAvatar", eventJson.getAsJsonPrimitive(LDUser.AVATAR).getAsString()); + assertEquals("US", eventJson.getAsJsonPrimitive(LDUser.COUNTRY).getAsString()); + assertEquals("foo@mail.co", eventJson.getAsJsonPrimitive(LDUser.EMAIL).getAsString()); + assertEquals("tester", eventJson.getAsJsonPrimitive(LDUser.FIRST_NAME).getAsString()); + assertEquals("one", eventJson.getAsJsonPrimitive(LDUser.LAST_NAME).getAsString()); + assertEquals("tester one", eventJson.getAsJsonPrimitive(LDUser.NAME).getAsString()); + assertEquals("1.1.1.1", eventJson.getAsJsonPrimitive(LDUser.IP).getAsString()); + assertEquals("b", eventJson.getAsJsonPrimitive(LDUser.SECONDARY).getAsString()); + JsonObject eventCustom = eventJson.getAsJsonObject("custom").getAsJsonObject(); + assertEquals(7, eventCustom.size()); + assertEquals(new JsonPrimitive("cvaluestring"), eventCustom.getAsJsonPrimitive("ckeystring")); + assertEquals(new JsonPrimitive(7.3), eventCustom.getAsJsonPrimitive("ckeynum")); + assertEquals(new JsonPrimitive(false), eventCustom.getAsJsonPrimitive("ckeybool")); + assertEquals(3, eventCustom.getAsJsonArray("ckeynumlist").size()); + assertTrue(eventCustom.getAsJsonArray("ckeynumlist").contains(new JsonPrimitive(1))); + assertTrue(eventCustom.getAsJsonArray("ckeynumlist").contains(new JsonPrimitive(2))); + assertTrue(eventCustom.getAsJsonArray("ckeynumlist").contains(new JsonPrimitive(3))); + assertEquals(2, eventCustom.getAsJsonArray("ckeystringlist").size()); + assertTrue(eventCustom.getAsJsonArray("ckeystringlist").contains(new JsonPrimitive("abc"))); + assertTrue(eventCustom.getAsJsonArray("ckeystringlist").contains(new JsonPrimitive("def"))); + } + + @Test + public void testBasicFieldsPrivate() { + LDUser.Builder builder = new LDUser.Builder("a") + .anonymous(true) + .privateAvatar("theAvatar") + .privateCountry("US") + .privateEmail("foo@mail.co") + .privateFirstName("tester") + .privateLastName("one") + .privateName("tester one") + .privateIp("1.1.1.1") + .privateSecondary("b") + .privateCustom("ckeystring", "cvaluestring") + .privateCustom("ckeynum", 7.3) + .privateCustom("ckeybool", false) + .privateCustomNumber("ckeynumlist", Arrays.asList(1, 2, 3)) + .privateCustomString("ckeystringlist", Arrays.asList("abc", "def")); + + LDUser ldUser = builder.build(); + assertEquals("a", ldUser.getKey()); + assertTrue(ldUser.getAnonymous()); + assertEquals("theAvatar", ldUser.getAvatar()); + assertEquals("US", ldUser.getCountry()); + assertEquals("foo@mail.co", ldUser.getEmail()); + assertEquals("tester", ldUser.getFirstName()); + assertEquals("one", ldUser.getLastName()); + assertEquals("tester one", ldUser.getName()); + assertEquals("1.1.1.1", ldUser.getIp()); + assertEquals("b", ldUser.getSecondary()); + assertEquals(new JsonPrimitive("cvaluestring"), ldUser.getCustom("ckeystring")); + assertEquals(new JsonPrimitive(7.3), ldUser.getCustom("ckeynum")); + assertEquals(new JsonPrimitive(false), ldUser.getCustom("ckeybool")); + assertEquals(3, ldUser.getCustom("ckeynumlist").getAsJsonArray().size()); + assertTrue(ldUser.getCustom("ckeynumlist").getAsJsonArray().contains(new JsonPrimitive(1))); + assertTrue(ldUser.getCustom("ckeynumlist").getAsJsonArray().contains(new JsonPrimitive(2))); + assertTrue(ldUser.getCustom("ckeynumlist").getAsJsonArray().contains(new JsonPrimitive(3))); + assertEquals(2, ldUser.getCustom("ckeystringlist").getAsJsonArray().size()); + assertTrue(ldUser.getCustom("ckeystringlist").getAsJsonArray().contains(new JsonPrimitive("abc"))); + assertTrue(ldUser.getCustom("ckeystringlist").getAsJsonArray().contains(new JsonPrimitive("def"))); + + assertEquals(13, ldUser.getPrivateAttributeNames().size()); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.AVATAR)); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.COUNTRY)); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.EMAIL)); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.FIRST_NAME)); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.LAST_NAME)); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.NAME)); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.IP)); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.SECONDARY)); + assertTrue(ldUser.getPrivateAttributeNames().contains("ckeystring")); + assertTrue(ldUser.getPrivateAttributeNames().contains("ckeynum")); + assertTrue(ldUser.getPrivateAttributeNames().contains("ckeybool")); + assertTrue(ldUser.getPrivateAttributeNames().contains("ckeynumlist")); + assertTrue(ldUser.getPrivateAttributeNames().contains("ckeystringlist")); + + JsonObject jsonUser = (new Gson()).fromJson(ldUser.getJson(), JsonObject.class); + assertEquals("a", jsonUser.getAsJsonPrimitive("key").getAsString()); + assertTrue(jsonUser.getAsJsonPrimitive("anonymous").getAsBoolean()); + assertEquals("theAvatar", jsonUser.getAsJsonPrimitive(LDUser.AVATAR).getAsString()); + assertEquals("US", jsonUser.get(LDUser.COUNTRY).getAsString()); + assertEquals("foo@mail.co", jsonUser.getAsJsonPrimitive(LDUser.EMAIL).getAsString()); + assertEquals("tester", jsonUser.getAsJsonPrimitive(LDUser.FIRST_NAME).getAsString()); + assertEquals("one", jsonUser.getAsJsonPrimitive(LDUser.LAST_NAME).getAsString()); + assertEquals("tester one", jsonUser.getAsJsonPrimitive(LDUser.NAME).getAsString()); + assertEquals("1.1.1.1", jsonUser.getAsJsonPrimitive(LDUser.IP).getAsString()); + assertEquals("b", jsonUser.getAsJsonPrimitive(LDUser.SECONDARY).getAsString()); + + LDConfig ldConfig = new LDConfig.Builder().build(); + JsonObject eventJson = ldConfig.getFilteredEventGson().toJsonTree(ldUser).getAsJsonObject(); + assertEquals(4, eventJson.size()); + assertEquals("a", eventJson.getAsJsonPrimitive("key").getAsString()); + assertTrue(eventJson.getAsJsonPrimitive("anonymous").getAsBoolean()); + assertEquals(13, eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).size()); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.AVATAR))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.COUNTRY))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.EMAIL))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.FIRST_NAME))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.LAST_NAME))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.NAME))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.IP))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive(LDUser.SECONDARY))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive("ckeystring"))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive("ckeynum"))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive("ckeybool"))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive("ckeynumlist"))); + assertTrue(eventJson.getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS).contains(new JsonPrimitive("ckeystringlist"))); + } + @Test public void testPrivateAttributesAreAddedToTheList() { LDUser.Builder builder = new LDUser.Builder("1") @@ -23,11 +207,11 @@ public void testPrivateAttributesAreAddedToTheList() { LDUser ldUser = builder.build(); - Assert.assertNotNull(ldUser); - Assert.assertEquals(ldUser.getPrivateAttributeNames().size(), 2); - Assert.assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.AVATAR)); - Assert.assertFalse(ldUser.getPrivateAttributeNames().contains(LDUser.EMAIL)); - Assert.assertTrue(ldUser.getPrivateAttributeNames().contains("privateValue1")); + assertNotNull(ldUser); + assertEquals(2, ldUser.getPrivateAttributeNames().size()); + assertTrue(ldUser.getPrivateAttributeNames().contains(LDUser.AVATAR)); + assertFalse(ldUser.getPrivateAttributeNames().contains(LDUser.EMAIL)); + assertTrue(ldUser.getPrivateAttributeNames().contains("privateValue1")); } @@ -40,10 +224,10 @@ public void testBuilderCustomWhenPrivateAttributesProvided() { Set privateAttributeNames = builder.getPrivateAttributeNames(); - Assert.assertNotNull(privateAttributeNames); - Assert.assertFalse(privateAttributeNames.contains("k1")); - Assert.assertTrue(privateAttributeNames.contains("k2")); - Assert.assertFalse(privateAttributeNames.contains("k3")); + assertNotNull(privateAttributeNames); + assertFalse(privateAttributeNames.contains("k1")); + assertTrue(privateAttributeNames.contains("k2")); + assertFalse(privateAttributeNames.contains("k3")); } @Test @@ -54,8 +238,8 @@ public void testBuilderCustomWhenPrivateAttributesNotProvided() { Set privateAttributeNames = builder.getPrivateAttributeNames(); - Assert.assertNotNull(privateAttributeNames); - Assert.assertEquals(privateAttributeNames.size(), 0); + assertNotNull(privateAttributeNames); + assertEquals(0, privateAttributeNames.size()); } @Test @@ -69,4 +253,18 @@ public void testModifyExistingUserPrivateAttributes() { existingUserBuilder.custom("k2", "v2"); } + @Test + public void testLDUserPrivateAttributesAdapter() { + LDUser.Builder builder = new LDUser.Builder("1") + .privateAvatar("privateAvatar") + .privateCustom("privateValue1", "123") + .email("email@server.net"); + LDUser user = builder.build(); + LDConfig ldConfig = new LDConfig.Builder().build(); + JsonElement element = ldConfig.getFilteredEventGson().toJsonTree(user); + JsonArray privateAttrs = element.getAsJsonObject().getAsJsonArray(LDUser.LDUserPrivateAttributesTypeAdapter.PRIVATE_ATTRS); + assertEquals(2, privateAttrs.size()); + assertTrue(privateAttrs.contains(new JsonPrimitive(LDUser.AVATAR))); + assertTrue(privateAttrs.contains(new JsonPrimitive("privateValue1"))); + } } 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 index ac0d07fe..16efe9dc 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MigrationTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MigrationTest.java @@ -5,11 +5,6 @@ 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; @@ -20,6 +15,11 @@ import java.io.File; import java.util.ArrayList; +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; + public class MigrationTest { private static final String FAKE_MOB_KEY = "mob-fakemob6-key9-fake-mob0-keyfakemob22"; @@ -28,6 +28,9 @@ public class MigrationTest { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + private Application getApplication() { return activityTestRule.getActivity().getApplication(); } 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 d8b2fcde..594a263d 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,10 +26,14 @@ @RunWith(AndroidJUnit4.class) public class MultiEnvironmentLDClientTest { + @Rule public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + private LDClient ldClient; private Future ldClientFuture; private LDConfig ldConfig; 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 7c2f6df4..7aa55e70 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 @@ -28,6 +28,9 @@ public class ThrottlerTest { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + private final AtomicBoolean hasRun = new AtomicBoolean(false); private Throttler throttler; private static final long MAX_RETRY_TIME_MS = 600000; diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/TimberLoggingRule.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/TimberLoggingRule.java new file mode 100644 index 00000000..25f4696b --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/TimberLoggingRule.java @@ -0,0 +1,18 @@ +package com.launchdarkly.android; + +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +import timber.log.Timber; + +public class TimberLoggingRule extends TestWatcher { + @Override + protected void starting(Description description) { + Timber.plant(new Timber.DebugTree()); + } + + @Override + protected void finished(Description description) { + Timber.uprootAll(); + } +} 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 aa4243ba..ecdc99db 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 @@ -2,6 +2,7 @@ import android.support.test.runner.AndroidJUnit4; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -11,6 +12,9 @@ @RunWith(AndroidJUnit4.class) public class UserHasherTest { + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Test public void testUserHasherReturnsUniqueResults(){ UserHasher userHasher1 = 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 29c096b5..c3b28998 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 @@ -40,6 +40,9 @@ public class UserManagerTest extends EasyMockSupport { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Rule public EasyMockRule easyMockRule = new EasyMockRule(this); diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserSummaryEventSharedPreferencesTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserSummaryEventSharedPreferencesTest.java index fcc20573..a54ef27a 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserSummaryEventSharedPreferencesTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserSummaryEventSharedPreferencesTest.java @@ -30,6 +30,9 @@ public class UserSummaryEventSharedPreferencesTest { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + private LDClient ldClient; private LDConfig ldConfig; private LDUser ldUser; 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 index 13f0b5e7..a795f589 100644 --- 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 @@ -55,13 +55,23 @@ public void testSwitchToUser() { } @Test - public void deletesOlderThanLastFiveStoredUsers() { + public void deletesOlderThanLastFiveStoredUsers() throws InterruptedException { 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}; + final FlagStoreFactory delegate = new FlagStoreFactory() { + @Override + public FlagStore createFlagStore(@NonNull String identifier) { + if (identifier.equals(oldestIdentifier.getValue())) { + oldestCountBox[0]++; + return oldestStore; + } + return fillerStore; + } + }; checkOrder(fillerStore, false); fillerStore.registerOnStoreUpdatedListener(anyObject(StoreUpdatedListener.class)); @@ -74,40 +84,25 @@ public void deletesOlderThanLastFiveStoredUsers() { 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; - } - }); + expect(mockCreate.createFlagStore(anyString())).andDelegateTo(delegate).times(6); oldestStore.delete(); expectLastCall(); replayAll(); + // Unfortunately we need to use Thread.sleep() to stagger the loading of users for this test + // otherwise the millisecond precision is not good enough to guarantee an ordering of the + // users for removing the oldest. manager.switchToUser("oldest"); + Thread.sleep(2); manager.switchToUser("fourth"); + Thread.sleep(2); manager.switchToUser("third"); + Thread.sleep(2); manager.switchToUser("second"); + Thread.sleep(2); manager.switchToUser("first"); + Thread.sleep(2); manager.switchToUser("new"); verifyAll(); 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 index 6d7fcd28..fa95ee45 100644 --- 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 @@ -8,8 +8,10 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.TimberLoggingRule; import com.launchdarkly.android.gson.GsonCache; +import org.junit.Rule; import org.junit.Test; import java.util.Arrays; @@ -34,6 +36,9 @@ public class FlagTest { .put(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND), "{\"kind\": \"ERROR\", \"errorKind\": \"FLAG_NOT_FOUND\"}") .build(); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Test public void keyIsSerialized() { final Flag r = new FlagBuilder("flag").build(); 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 index 0b5f9494..925e81cf 100644 --- 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 @@ -4,6 +4,7 @@ import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; +import com.launchdarkly.android.TimberLoggingRule; import com.launchdarkly.android.flagstore.FlagStore; import com.launchdarkly.android.test.TestActivity; @@ -16,6 +17,9 @@ @RunWith(AndroidJUnit4.class) public class SharedPrefsFlagStoreFactoryTest { + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Rule public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); 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 index a296c5bd..ad233286 100644 --- 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 @@ -4,6 +4,7 @@ import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; +import com.launchdarkly.android.TimberLoggingRule; import com.launchdarkly.android.flagstore.FlagStoreFactory; import com.launchdarkly.android.flagstore.FlagStoreManager; import com.launchdarkly.android.flagstore.FlagStoreManagerTest; @@ -22,6 +23,9 @@ public class SharedPrefsFlagStoreManagerTest extends FlagStoreManagerTest { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Before public void setUp() { this.testApplication = activityTestRule.getActivity().getApplication(); 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 index 050fec7f..13bba83e 100644 --- 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 @@ -5,6 +5,7 @@ import android.support.test.runner.AndroidJUnit4; import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.TimberLoggingRule; import com.launchdarkly.android.flagstore.Flag; import com.launchdarkly.android.flagstore.FlagBuilder; import com.launchdarkly.android.flagstore.FlagStore; @@ -34,6 +35,9 @@ public class SharedPrefsFlagStoreTest extends FlagStoreTest { public final ActivityTestRule activityTestRule = new ActivityTestRule<>(TestActivity.class, false, true); + @Rule + public TimberLoggingRule timberLoggingRule = new TimberLoggingRule(); + @Before public void setUp() { this.testApplication = activityTestRule.getActivity().getApplication(); 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 da9d4efa..502bb31d 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 @@ -41,7 +41,7 @@ class GenericEvent extends Event { class IdentifyEvent extends GenericEvent { IdentifyEvent(LDUser user) { - super("identify", user.getKeyAsString(), user); + super("identify", user.getKey(), user); } } 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 fc904ca6..0bbe0eef 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 @@ -278,7 +278,7 @@ public void track(String eventName, JsonElement data) { if (config.inlineUsersInEvents()) { sendEvent(new CustomEvent(eventName, userManager.getCurrentUser(), data)); } else { - sendEvent(new CustomEvent(eventName, userManager.getCurrentUser().getKeyAsString(), data)); + sendEvent(new CustomEvent(eventName, userManager.getCurrentUser().getKey(), data)); } } @@ -445,7 +445,7 @@ private EvaluationDetail variationDetailInternal(String flagKey, T fallba 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()); + Timber.d("returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKey()); return result; } @@ -600,7 +600,7 @@ private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, if (config.inlineUsersInEvents()) { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser(), value, fallback, version, variation, reason)); } else { - sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser().getKeyAsString(), value, fallback, version, variation, reason)); + sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser().getKey(), value, fallback, version, variation, reason)); } } else { Long debugEventsUntilDate = flag.getDebugEventsUntilDate(); 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 d87dbb54..57d5f986 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 @@ -10,7 +10,6 @@ import com.google.gson.JsonPrimitive; import com.google.gson.TypeAdapter; import com.google.gson.annotations.Expose; -import com.google.gson.internal.Streams; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; @@ -26,16 +25,19 @@ import timber.log.Timber; /** - * A {@code LDUser} object contains specific attributes of a user browsing your site. The only mandatory property property is the {@code key}, - * which must uniquely identify each user. For authenticated users, this may be a username or e-mail address. For anonymous users, - * this could be an IP address or session ID. + * A {@code LDUser} object contains specific attributes of a user browsing your site. The only + * mandatory property property is the {@code key}, which must uniquely identify each user. For + * authenticated users, this may be a username or e-mail address. For anonymous users, this could be + * an IP address or session ID. *

    - * Besides the mandatory {@code key}, {@code LDUser} supports two kinds of optional attributes: interpreted attributes (e.g. {@code ip} and {@code country}) - * and custom attributes. LaunchDarkly can parse interpreted attributes and attach meaning to them. For example, from an {@code ip} address, LaunchDarkly can - * do a geo IP lookup and determine the user's country. + * Besides the mandatory {@code key}, {@code LDUser} supports two kinds of optional attributes: + * interpreted attributes (e.g. {@code ip} and {@code country}) and custom attributes. LaunchDarkly + * can parse interpreted attributes and attach meaning to them. For example, from an {@code ip} + * address, LaunchDarkly can do a geo IP lookup and determine the user's country. *

    - * Custom attributes are not parsed by LaunchDarkly. They can be used in custom rules-- for example, a custom attribute such as "customer_ranking" can be used to - * launch a feature to the top 10% of users on a site. + * Custom attributes are not parsed by LaunchDarkly. They can be used in custom rules-- for example, + * a custom attribute such as "customer_ranking" can be used to launch a feature to the top 10% of + * users on a site. */ public class LDUser { private static final UserHasher USER_HASHER = new UserHasher(); @@ -46,6 +48,9 @@ public class LDUser { private static final String DEVICE = "device"; private static final String OS = "os"; + private static final String[] builtInAttributes = {"key", "secondary", "ip", "email", "avatar" + , "firstName", "lastName", "name", "country", "anonymous"}; + static final String IP = "ip"; static final String COUNTRY = "country"; static final String SECONDARY = "secondary"; @@ -56,26 +61,26 @@ public class LDUser { static final String AVATAR = "avatar"; @Expose - private final JsonPrimitive key; + private final String key; @Expose - private final JsonPrimitive anonymous; + private final Boolean anonymous; @Expose - private final JsonPrimitive secondary; + private final String secondary; @Expose - private final JsonPrimitive ip; + private final String ip; @Expose - private final JsonPrimitive email; + private final String email; @Expose - private final JsonPrimitive name; + private final String name; @Expose - private final JsonPrimitive avatar; + private final String avatar; @Expose - private final JsonPrimitive firstName; + private final String firstName; @Expose - private final JsonPrimitive lastName; + private final String lastName; @Expose - private final JsonPrimitive country; + private final String country; @Expose private final Map custom; @@ -93,27 +98,28 @@ public class LDUser { protected LDUser(Builder builder) { if (builder.key == null || builder.key.equals("")) { Timber.w("User was created with null/empty key. Using device-unique anonymous user key: %s", LDClient.getInstanceId()); - this.key = new JsonPrimitive(LDClient.getInstanceId()); - this.anonymous = new JsonPrimitive(true); + this.key = LDClient.getInstanceId(); + this.anonymous = true; } else { - this.key = new JsonPrimitive(builder.key); - this.anonymous = builder.anonymous == null ? null : new JsonPrimitive(builder.anonymous); - } - - this.ip = builder.ip == null ? null : new JsonPrimitive(builder.ip); - this.country = builder.country == null ? null : new JsonPrimitive(builder.country.getAlpha2()); - this.secondary = builder.secondary == null ? null : new JsonPrimitive(builder.secondary); - this.firstName = builder.firstName == null ? null : new JsonPrimitive(builder.firstName); - this.lastName = builder.lastName == null ? null : new JsonPrimitive(builder.lastName); - this.email = builder.email == null ? null : new JsonPrimitive(builder.email); - this.name = builder.name == null ? null : new JsonPrimitive(builder.name); - this.avatar = builder.avatar == null ? null : new JsonPrimitive(builder.avatar); + this.key = builder.key; + this.anonymous = builder.anonymous; + } + + this.ip = builder.ip; + this.country = builder.country == null ? null : (builder.country.getAlpha2()); + this.secondary = builder.secondary; + this.firstName = builder.firstName; + this.lastName = builder.lastName; + this.email = builder.email; + this.name = builder.name; + this.avatar = builder.avatar; this.custom = Collections.unmodifiableMap(builder.custom); this.privateAttributeNames = builder.privateAttributeNames; String userJson = getJson(); - this.urlSafeBase64 = Base64.encodeToString(userJson.getBytes(), Base64.URL_SAFE + Base64.NO_WRAP); + this.urlSafeBase64 = Base64.encodeToString(userJson.getBytes(), + Base64.URL_SAFE + Base64.NO_WRAP); this.sharedPrefsKey = USER_HASHER.hash(userJson); } @@ -127,51 +133,43 @@ String getAsUrlSafeBase64() { return urlSafeBase64; } - JsonPrimitive getKey() { + String getKey() { return key; } - String getKeyAsString() { - if (key == null) { - return ""; - } else { - return key.getAsString(); - } - } - - JsonPrimitive getIp() { + String getIp() { return ip; } - JsonPrimitive getCountry() { + String getCountry() { return country; } - JsonPrimitive getSecondary() { + String getSecondary() { return secondary; } - JsonPrimitive getName() { + String getName() { return name; } - JsonPrimitive getFirstName() { + String getFirstName() { return firstName; } - JsonPrimitive getLastName() { + String getLastName() { return lastName; } - JsonPrimitive getEmail() { + String getEmail() { return email; } - JsonPrimitive getAvatar() { + String getAvatar() { return avatar; } - JsonPrimitive getAnonymous() { + Boolean getAnonymous() { return anonymous; } @@ -192,8 +190,8 @@ String getSharedPrefsKey() { } /** - * A builder that helps construct {@link LDUser} objects. Builder - * calls can be chained, enabling the following pattern: + * A builder that helps construct + * {@link LDUser} objects. Builder calls can be chained, enabling the following pattern: *

    *

          * LDUser user = new LDUser.Builder("key")
    @@ -236,22 +234,17 @@ public Builder(String key) {
             }
     
             public Builder(LDUser user) {
    -            JsonPrimitive userKey = user.getKey();
    -            if (userKey.isJsonNull()) {
    -                this.key = null;
    -            } else {
    -                this.key = user.getKeyAsString();
    -            }
    -            this.anonymous = user.getAnonymous() != null ? user.getAnonymous().getAsBoolean() : null;
    -
    -            this.secondary = user.getSecondary() != null ? user.getSecondary().getAsString() : null;
    -            this.ip = user.getIp() != null ? user.getIp().getAsString() : null;
    -            this.firstName = user.getFirstName() != null ? user.getFirstName().getAsString() : null;
    -            this.lastName = user.getLastName() != null ? user.getLastName().getAsString() : null;
    -            this.email = user.getEmail() != null ? user.getEmail().getAsString() : null;
    -            this.name = user.getName() != null ? user.getName().getAsString() : null;
    -            this.avatar = user.getAvatar() != null ? user.getAvatar().getAsString() : null;
    -            this.country = user.getCountry() != null ? LDCountryCode.valueOf(user.getCountry().getAsString()) : null;
    +            this.key = user.getKey();
    +            this.anonymous = user.getAnonymous();
    +            this.secondary = user.getSecondary();
    +            this.ip = user.getIp();
    +            this.firstName = user.getFirstName();
    +            this.lastName = user.getLastName();
    +            this.email = user.getEmail();
    +            this.name = user.getName();
    +            this.avatar = user.getAvatar();
    +            this.country = user.getCountry() != null ? LDCountryCode.valueOf(user.getCountry()) :
    +                    null;
                 this.custom = new HashMap<>(user.custom);
     
                 this.privateAttributeNames = new HashSet<>(user.getPrivateAttributeNames());
    @@ -269,8 +262,7 @@ public Builder ip(String s) {
             }
     
             /**
    -         * Set the IP for a user
    -         * Private attributes are not sent to the server.
    +         * Set the IP for a user. Private attributes are not recorded in events.
              *
              * @param s the IP address for the user
              * @return the builder
    @@ -291,9 +283,10 @@ public Builder privateSecondary(String s) {
             }
     
             /**
    -         * Set the country for a user. The country should be a valid ISO 3166-1
    -         * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name.
    -         * If that fails, a warning will be logged, and the country will not be set.
    +         * Set the country for a user. The country should be a valid ISO 3166-1 alpha-2 or alpha-3 code. If
    +         * it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its
    +         * name. If that fails, a warning will be logged, and the country will not be set.
              *
              * @param s the country for the user
              * @return the builder
    @@ -303,12 +296,12 @@ public Builder country(String s) {
                 return this;
             }
     
    -
             /**
    -         * Set the country for a user. The country should be a valid ISO 3166-1
    -         * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name.
    -         * If that fails, a warning will be logged, and the country will not be set.
    -         * Private attributes are not sent to the server.
    +         * Set the country for a user. The country should be a valid ISO 3166-1 alpha-2 or alpha-3 code. If
    +         * it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its
    +         * name. If that fails, a warning will be logged, and the country will not be set. Private
    +         * attributes are not recorded in events.
              *
              * @param s the country for the user
              * @return the builder
    @@ -356,8 +349,7 @@ public Builder country(LDCountryCode country) {
             }
     
             /**
    -         * Set the country for a user.
    -         * Private attributes are not sent to the server.
    +         * Set the country for a user. Private attributes are not recorded in events.
              *
              * @param country the country for the user
              * @return the builder
    @@ -379,8 +371,7 @@ public Builder firstName(String firstName) {
             }
     
             /**
    -         * Sets the user's first name
    -         * Private attributes are not sent to the server.
    +         * Sets the user's first name. Private attributes are not recorded in events.
              *
              * @param firstName the user's first name
              * @return the builder
    @@ -413,8 +404,7 @@ public Builder lastName(String lastName) {
             }
     
             /**
    -         * Sets the user's last name
    -         * Private attributes are not sent to the server.
    +         * Sets the user's last name. Private attributes are not recorded in events.
              *
              * @param lastName the user's last name
              * @return the builder
    @@ -436,8 +426,7 @@ public Builder name(String name) {
             }
     
             /**
    -         * Sets the user's full name
    -         * Private attributes are not sent to the server.
    +         * Sets the user's full name. Private attributes are not recorded in events.
              *
              * @param name the user's full name
              * @return the builder
    @@ -459,8 +448,7 @@ public Builder avatar(String avatar) {
             }
     
             /**
    -         * Sets the user's avatar
    -         * Private attributes are not sent to the server.
    +         * Sets the user's avatar. Private attributes are not recorded in events.
              *
              * @param avatar the user's avatar
              * @return the builder
    @@ -482,8 +470,7 @@ public Builder email(String email) {
             }
     
             /**
    -         * Sets the user's e-mail address
    -         * Private attributes are not sent to the server.
    +         * Sets the user's e-mail address. Private attributes are not recorded in events.
              *
              * @param email the e-mail address
              * @return the builder
    @@ -493,6 +480,24 @@ public Builder privateEmail(String email) {
                 return email(email);
             }
     
    +        private void checkCustomAttribute(String key) {
    +            for (String attributeName : builtInAttributes) {
    +                if (attributeName.equals(key)) {
    +                    Timber.w("Built-in attribute key: %s added as custom attribute! This custom " +
    +                            "attribute will be ignored during Feature Flag evaluation", key);
    +                    return;
    +                }
    +            }
    +        }
    +
    +        private Builder customJson(String k, JsonElement v) {
    +            checkCustomAttribute(k);
    +            if (k != null && v != null) {
    +                custom.put(k, v);
    +            }
    +            return this;
    +        }
    +
             /**
              * Add a {@link String}-valued custom attribute. When set to one of the
              * 
    @@ -503,14 +508,14 @@ public Builder privateEmail(String email) {
              * @return the builder
              */
             public Builder custom(String k, String v) {
    -            return custom(custom, k, new JsonPrimitive(v));
    +            return customJson(k, new JsonPrimitive(v));
             }
     
             /**
              * Add a {@link String}-valued custom attribute. When set to one of the
              * 
    -         * built-in user attribute keys, this custom attribute will be ignored.
    -         * Private attributes are not sent to the server.
    +         * built-in user attribute keys, this custom attribute will be ignored. Private
    +         * attributes are not recorded in events.
              *
              * @param k the key for the custom attribute.
              * @param v the value for the custom attribute
    @@ -518,15 +523,7 @@ public Builder custom(String k, String v) {
              */
             public Builder privateCustom(String k, String v) {
                 privateAttributeNames.add(k);
    -            return custom(k, v);
    -        }
    -
    -        private  Builder custom(Map map, String k, T v) {
    -            checkCustomAttribute(k);
    -            if (k != null && v != null) {
    -                map.put(k, v);
    -            }
    -            return this;
    +            return customJson(k, new JsonPrimitive(v));
             }
     
             /**
    @@ -534,27 +531,29 @@ private  Builder custom(Map map, String k, T v) {
              * 
              * built-in user attribute keys, this custom attribute will be ignored.
              *
    -         * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k the key for the custom attribute. When set to one of the built-in user attribute
    +         *          keys, this custom attribute will be ignored.
              * @param n the value for the custom attribute
              * @return the builder
              */
             public Builder custom(String k, Number n) {
    -            return custom(custom, k, new JsonPrimitive(n));
    +            return customJson(k, new JsonPrimitive(n));
             }
     
             /**
              * Add a {@link Number}-valued custom attribute. When set to one of the
              * 
    -         * built-in user attribute keys, this custom attribute will be ignored.
    -         * Private attributes are not sent to the server.
    +         * built-in user attribute keys, this custom attribute will be ignored. Private
    +         * attributes are not recorded in events.
              *
    -         * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k the key for the custom attribute. When set to one of the built-in user attribute
    +         *          keys, this custom attribute will be ignored.
              * @param n the value for the custom attribute
              * @return the builder
              */
             public Builder privateCustom(String k, Number n) {
                 privateAttributeNames.add(k);
    -            return custom(k, n);
    +            return customJson(k, new JsonPrimitive(n));
             }
     
             /**
    @@ -562,27 +561,29 @@ public Builder privateCustom(String k, Number n) {
              * 
              * built-in user attribute keys, this custom attribute will be ignored.
              *
    -         * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k the key for the custom attribute. When set to one of the built-in user attribute
    +         *          keys, this custom attribute will be ignored.
              * @param b the value for the custom attribute
              * @return the builder
              */
             public Builder custom(String k, Boolean b) {
    -            return custom(custom, k, new JsonPrimitive(b));
    +            return customJson(k, new JsonPrimitive(b));
             }
     
             /**
              * Add a {@link Boolean}-valued custom attribute. When set to one of the
              * 
    -         * built-in user attribute keys, this custom attribute will be ignored.
    -         * Private attributes are not sent to the server.
    +         * built-in user attribute keys, this custom attribute will be ignored. Private
    +         * attributes are not recorded in events.
              *
    -         * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k the key for the custom attribute. When set to one of the built-in user attribute
    +         *          keys, this custom attribute will be ignored.
              * @param b the value for the custom attribute
              * @return the builder
              */
             public Builder privateCustom(String k, Boolean b) {
                 privateAttributeNames.add(k);
    -            return custom(k, b);
    +            return customJson(k, new JsonPrimitive(b));
             }
     
             /**
    @@ -590,13 +591,15 @@ public Builder privateCustom(String k, Boolean b) {
              * 
              * built-in user attribute keys, this custom attribute will be ignored.
              *
    -         * @param k  the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k  the key for the list. When set to one of the built-in user attribute keys, this
    +         *           custom attribute will be ignored.
              * @param vs the values for the attribute
              * @return the builder
    -         * @deprecated As of version 0.16.0, renamed to {@link #customString(String, List) customString}
    +         * @deprecated As of version 0.16.0, renamed to {@link #customString(String, List)
    +         * customString}
              */
             public Builder custom(String k, List vs) {
    -            return custom(custom, k, vs);
    +            return customString(k, vs);
             }
     
             /**
    @@ -604,21 +607,29 @@ public Builder custom(String k, List vs) {
              * 
              * built-in user attribute keys, this custom attribute will be ignored.
              *
    -         * @param k  the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k  the key for the list. When set to one of the built-in user attribute keys, this
    +         *           custom attribute will be ignored.
              * @param vs the values for the attribute
              * @return the builder
              */
             public Builder customString(String k, List vs) {
    -            return custom(custom, k, vs);
    +            JsonArray array = new JsonArray();
    +            for (String v : vs) {
    +                if (v != null) {
    +                    array.add(new JsonPrimitive(v));
    +                }
    +            }
    +            return customJson(k, array);
             }
     
             /**
              * Add a list of {@link String}-valued custom attributes. When set to one of the
              * 
    -         * built-in user attribute keys, this custom attribute will be ignored.
    -         * Private attributes are not sent to the server.
    +         * built-in user attribute keys, this custom attribute will be ignored. Private
    +         * attributes are not recorded in events.
              *
    -         * @param k  the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k  the key for the list. When set to one of the built-in user attribute keys, this
    +         *           custom attribute will be ignored.
              * @param vs the values for the attribute
              * @return the builder
              */
    @@ -627,38 +638,34 @@ public Builder privateCustomString(String k, List vs) {
                 return customString(k, vs);
             }
     
    -        private Builder custom(Map map, String k, List vs) {
    -            checkCustomAttribute(k);
    -            JsonArray array = new JsonArray();
    -            for (String v : vs) {
    -                if (v != null) {
    -                    array.add(new JsonPrimitive(v));
    -                }
    -            }
    -            custom.put(k, array);
    -            return this;
    -        }
    -
             /**
              * Add a list of {@link Integer}-valued custom attributes. When set to one of the
              * 
              * built-in user attribute keys, this custom attribute will be ignored.
              *
    -         * @param k  the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k  the key for the list. When set to one of the built-in user attribute keys, this
    +         *           custom attribute will be ignored.
              * @param vs the values for the attribute
              * @return the builder
              */
             public Builder customNumber(String k, List vs) {
    -            return customNumber(custom, k, vs);
    +            JsonArray array = new JsonArray();
    +            for (Number v : vs) {
    +                if (v != null) {
    +                    array.add(new JsonPrimitive(v));
    +                }
    +            }
    +            return customJson(k, array);
             }
     
             /**
              * Add a list of {@link Integer}-valued custom attributes. When set to one of the
              * 
    -         * built-in user attribute keys, this custom attribute will be ignored.
    -         * Private attributes are not sent to the server.
    +         * built-in user attribute keys, this custom attribute will be ignored. Private
    +         * attributes are not recorded in events.
              *
    -         * @param k  the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored.
    +         * @param k  the key for the list. When set to one of the built-in user attribute keys, this
    +         *           custom attribute will be ignored.
              * @param vs the values for the attribute
              * @return the builder
              */
    @@ -667,27 +674,6 @@ public Builder privateCustomNumber(String k, List vs) {
                 return customNumber(k, vs);
             }
     
    -        private Builder customNumber(Map map, String k, List vs) {
    -            checkCustomAttribute(k);
    -            JsonArray array = new JsonArray();
    -            for (Number v : vs) {
    -                if (v != null) {
    -                    array.add(new JsonPrimitive(v));
    -                }
    -            }
    -            map.put(k, array);
    -            return this;
    -        }
    -
    -        private void checkCustomAttribute(String key) {
    -            for (UserAttribute a : UserAttribute.values()) {
    -                if (a.name().equals(key)) {
    -                    Timber.w("Built-in attribute key: %s added as custom attribute! This custom attribute will be ignored during Feature Flag evaluation", key);
    -                    return;
    -                }
    -            }
    -        }
    -
             @VisibleForTesting
             @NonNull
             Set getPrivateAttributeNames() {
    @@ -724,60 +710,27 @@ public void write(JsonWriter out, LDUser user) throws IOException {
                 }
     
                 // Collect the private attribute names
    -            Set privateAttributeNames = new HashSet<>(config.getPrivateAttributeNames());
    +            Set privateAttrs = new HashSet<>();
     
                 out.beginObject();
                 // The key can never be private
    -            out.name(LDUser.KEY).value(user.getKeyAsString());
    -
    -            if (user.getSecondary() != null) {
    -                if (!checkAndAddPrivate(LDUser.SECONDARY, user, privateAttributeNames)) {
    -                    out.name(LDUser.SECONDARY).value(user.getSecondary().getAsString());
    -                }
    -            }
    -            if (user.getIp() != null) {
    -                if (!checkAndAddPrivate(LDUser.IP, user, privateAttributeNames)) {
    -                    out.name(LDUser.IP).value(user.getIp().getAsString());
    -                }
    -            }
    -            if (user.getEmail() != null) {
    -                if (!checkAndAddPrivate(LDUser.EMAIL, user, privateAttributeNames)) {
    -                    out.name(LDUser.EMAIL).value(user.getEmail().getAsString());
    -                }
    -            }
    -            if (user.getName() != null) {
    -                if (!checkAndAddPrivate(LDUser.NAME, user, privateAttributeNames)) {
    -                    out.name(LDUser.NAME).value(user.getName().getAsString());
    -                }
    -            }
    -            if (user.getAvatar() != null) {
    -                if (!checkAndAddPrivate(LDUser.AVATAR, user, privateAttributeNames)) {
    -                    out.name(LDUser.AVATAR).value(user.getAvatar().getAsString());
    -                }
    -            }
    -            if (user.getFirstName() != null) {
    -                if (!checkAndAddPrivate(LDUser.FIRST_NAME, user, privateAttributeNames)) {
    -                    out.name(LDUser.FIRST_NAME).value(user.getFirstName().getAsString());
    -                }
    -            }
    -            if (user.getLastName() != null) {
    -                if (!checkAndAddPrivate(LDUser.LAST_NAME, user, privateAttributeNames)) {
    -                    out.name(LDUser.LAST_NAME).value(user.getLastName().getAsString());
    -                }
    -            }
    +            out.name(LDUser.KEY).value(user.getKey());
                 if (user.getAnonymous() != null) {
    -                out.name(LDUser.ANONYMOUS).value(user.getAnonymous().getAsBoolean());
    +                out.name(LDUser.ANONYMOUS).value(user.getAnonymous());
                 }
    -            if (user.getCountry() != null) {
    -                if (!checkAndAddPrivate(LDUser.COUNTRY, user, privateAttributeNames)) {
    -                    out.name(LDUser.COUNTRY).value(user.getCountry().getAsString());
    -                }
    -            }
    -            writeCustomAttrs(out, user, privateAttributeNames);
    -            writePrivateAttrNames(out, privateAttributeNames);
     
    -            out.endObject();
    +            checkAndWriteString(out, user, LDUser.SECONDARY, user.getSecondary(), privateAttrs);
    +            checkAndWriteString(out, user, LDUser.IP, user.getIp(), privateAttrs);
    +            checkAndWriteString(out, user, LDUser.EMAIL, user.getEmail(), privateAttrs);
    +            checkAndWriteString(out, user, LDUser.NAME, user.getName(), privateAttrs);
    +            checkAndWriteString(out, user, LDUser.AVATAR, user.getAvatar(), privateAttrs);
    +            checkAndWriteString(out, user, LDUser.FIRST_NAME, user.getFirstName(), privateAttrs);
    +            checkAndWriteString(out, user, LDUser.LAST_NAME, user.getLastName(), privateAttrs);
    +            checkAndWriteString(out, user, LDUser.COUNTRY, user.getCountry(), privateAttrs);
    +            writeCustomAttrs(out, user, privateAttrs);
    +            writePrivateAttrNames(out, privateAttrs);
     
    +            out.endObject();
             }
     
             @Override
    @@ -785,23 +738,20 @@ public LDUser read(JsonReader in) throws IOException {
                 return LDConfig.GSON.fromJson(in, LDUser.class);
             }
     
    -        private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAttributeNames) throws IOException {
    +        private void writeCustomAttrs(JsonWriter out, LDUser user,
    +                                      Set privateAttrs) throws IOException {
                 boolean beganObject = false;
    -            if (user.custom == null) {
    -                return;
    -            }
                 for (Map.Entry entry : user.custom.entrySet()) {
    -                if (!checkAndAddPrivate(entry.getKey(), user, privateAttributeNames)) {
    +                if (isPrivate(entry.getKey(), user)) {
    +                    privateAttrs.add(entry.getKey());
    +                } else {
                         if (!beganObject) {
                             out.name(LDUser.CUSTOM);
                             out.beginObject();
                             beganObject = true;
                         }
                         out.name(entry.getKey());
    -                    // this accesses part of the internal GSON api. However, it's likely
    -                    // the only way to write a JsonElement directly:
    -                    // https://groups.google.com/forum/#!topic/google-gson/JpHbpZ9mTOk
    -                    Streams.write(entry.getValue(), out);
    +                    LDConfig.GSON.getAdapter(JsonElement.class).write(out, entry.getValue());
                     }
                 }
                 if (beganObject) {
    @@ -809,6 +759,18 @@ private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAt
                 }
             }
     
    +        private void checkAndWriteString(JsonWriter out, LDUser user, String key, String value,
    +                                         Set privateAttrs) throws IOException {
    +            if (value == null) {
    +                return;
    +            }
    +            if (isPrivate(key, user)) {
    +                privateAttrs.add(key);
    +            } else {
    +                out.name(key).value(value);
    +            }
    +        }
    +
             private void writePrivateAttrNames(JsonWriter out, Set names) throws IOException {
                 if (names.isEmpty()) {
                     return;
    @@ -821,16 +783,11 @@ private void writePrivateAttrNames(JsonWriter out, Set names) throws IOE
                 out.endArray();
             }
     
    -        private boolean checkAndAddPrivate(String key, LDUser user, Set privateAttrs) {
    +        private boolean isPrivate(String key, LDUser user) {
                 boolean result = config.allAttributesPrivate()
                         || config.getPrivateAttributeNames().contains(key)
                         || user.getPrivateAttributeNames().contains(key);
    -            result = result && (!key.equals(LDUser.DEVICE) && !key.equals(LDUser.OS));
    -
    -            if (result) {
    -                privateAttrs.add(key);
    -            }
    -            return result;
    +            return result && !key.equals(LDUser.DEVICE) && !key.equals(LDUser.OS);
             }
     
         }
    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
    index 3b81bd21..6bb4b64e 100644
    --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java
    +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java
    @@ -140,6 +140,9 @@ private static void migrate_2_7_from_2_6(Application application) {
         static ArrayList getUserKeysPre_2_6(Application application, LDConfig config) {
             File directory = new File(application.getFilesDir().getParent() + "/shared_prefs/");
             File[] files = directory.listFiles();
    +        if (files == null) {
    +            return new ArrayList<>();
    +        }
             ArrayList filenames = new ArrayList<>();
             for (File file : files) {
                 if (file.isFile())
    diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserAttribute.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserAttribute.java
    deleted file mode 100644
    index 595bb3ad..00000000
    --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserAttribute.java
    +++ /dev/null
    @@ -1,65 +0,0 @@
    -package com.launchdarkly.android;
    -
    -
    -import com.google.gson.JsonElement;
    -
    -enum UserAttribute {
    -    key {
    -        JsonElement get(LDUser user) {
    -            return user.getKey();
    -        }
    -    },
    -    secondary {
    -        JsonElement get(LDUser user) {
    -            return null; //Not used for evaluation.
    -        }
    -    },
    -    ip {
    -        JsonElement get(LDUser user) {
    -            return user.getIp();
    -        }
    -    },
    -    email {
    -        JsonElement get(LDUser user) {
    -            return user.getEmail();
    -        }
    -    },
    -    avatar {
    -        JsonElement get(LDUser user) {
    -            return user.getAvatar();
    -        }
    -    },
    -    firstName {
    -        JsonElement get(LDUser user) {
    -            return user.getFirstName();
    -        }
    -    },
    -    lastName {
    -        JsonElement get(LDUser user) {
    -            return user.getLastName();
    -        }
    -    },
    -    name {
    -        JsonElement get(LDUser user) {
    -            return user.getName();
    -        }
    -    },
    -    country {
    -        JsonElement get(LDUser user) {
    -            return user.getCountry();
    -        }
    -    },
    -    anonymous {
    -        JsonElement get(LDUser user) {
    -            return user.getAnonymous();
    -        }
    -    };
    -
    -    /**
    -     * Gets value for Rule evaluation for a user.
    -     *
    -     * @param user
    -     * @return
    -     */
    -    abstract JsonElement get(LDUser user);
    -}
    
    From 4e76a480d4ae6df1ba840bd6d12a24f691e30e7c Mon Sep 17 00:00:00 2001
    From: Gavin Whelan 
    Date: Fri, 19 Apr 2019 23:24:20 +0000
    Subject: [PATCH 085/152] Add metricValue field to CustomEvent, add overloaded
     track method for (#118)
    
    creating custom events with metricValues.
    ---
     .../java/com/launchdarkly/android/EventTest.java     |  4 ++--
     .../main/java/com/launchdarkly/android/Event.java    |  8 ++++++--
     .../main/java/com/launchdarkly/android/LDClient.java | 11 ++++++++---
     .../com/launchdarkly/android/LDClientInterface.java  | 12 ++++++++++++
     4 files changed, 28 insertions(+), 7 deletions(-)
    
    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 a04d6342..bfe4e2d3 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
    @@ -233,7 +233,7 @@ public void testUserObjectRemovedFromCustomEvent() {
     
             LDUser user = builder.build();
     
    -        final CustomEvent event = new CustomEvent("key1", user.getKey(), null);
    +        final CustomEvent event = new CustomEvent("key1", user.getKey(), null, null);
     
             assertNull(event.user);
             assertEquals(user.getKey(), event.userKey);
    @@ -246,7 +246,7 @@ public void testFullUserObjectIncludedInCustomEvent() {
     
             LDUser user = builder.build();
     
    -        final CustomEvent event = new CustomEvent("key1", user, null);
    +        final CustomEvent event = new CustomEvent("key1", user, null, null);
     
             assertEquals(user, event.user);
             assertNull(event.userKey);
    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 502bb31d..43f2b8a2 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
    @@ -48,16 +48,20 @@ class IdentifyEvent extends GenericEvent {
     class CustomEvent extends GenericEvent {
         @Expose
         private final JsonElement data;
    +    @Expose
    +    private final Double metricValue;
     
    -    CustomEvent(String key, LDUser user, JsonElement data) {
    +    CustomEvent(String key, LDUser user, JsonElement data, Double metricValue) {
             super("custom", key, user);
             this.data = data;
    +        this.metricValue = metricValue;
         }
     
    -    CustomEvent(String key, String userKey, JsonElement data) {
    +    CustomEvent(String key, String userKey, JsonElement data, Double metricValue) {
             super("custom", key, null);
             this.data = data;
             this.userKey = userKey;
    +        this.metricValue = metricValue;
         }
     }
     
    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 0bbe0eef..a3e203f2 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
    @@ -274,14 +274,19 @@ public void run() {
         }
     
         @Override
    -    public void track(String eventName, JsonElement data) {
    +    public void track(String eventName, JsonElement data, Double metricValue) {
             if (config.inlineUsersInEvents()) {
    -            sendEvent(new CustomEvent(eventName, userManager.getCurrentUser(), data));
    +            sendEvent(new CustomEvent(eventName, userManager.getCurrentUser(), data, metricValue));
             } else {
    -            sendEvent(new CustomEvent(eventName, userManager.getCurrentUser().getKey(), data));
    +            sendEvent(new CustomEvent(eventName, userManager.getCurrentUser().getKey(), data, metricValue));
             }
         }
     
    +    @Override
    +    public void track(String eventName, JsonElement data) {
    +        track(eventName, data, null);
    +    }
    +
         @Override
         public void track(String eventName) {
             track(eventName, null);
    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 c82b396e..8c2ac3e4 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
    @@ -54,6 +54,18 @@ public interface LDClientInterface extends Closeable {
          */
         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
    +     * @param metricValue A numeric value used by the LaunchDarkly experimentation feature in
    +     *                    numeric custom metrics. Can be omitted if this event is used by only
    +     *                    non-numeric metrics. This field will also be returned as part of the
    +     *                    custom event for Data Export. (Optional)
    +     */
    +    void track(String eventName, JsonElement data, Double metricValue);
    +
         /**
          * Tracks that a user performed an event.
          *
    
    From 5cca201bbb16838a68b39c01e8897f7627f033e9 Mon Sep 17 00:00:00 2001
    From: Gavin Whelan 
    Date: Mon, 29 Apr 2019 19:32:36 +0000
    Subject: [PATCH 086/152] [ch37794] Run connected emulator tests in CircleCI
     (#120)
    
    ---
     .circleci/config.yml | 23 ++++++++++++++++++++---
     1 file changed, 20 insertions(+), 3 deletions(-)
    
    diff --git a/.circleci/config.yml b/.circleci/config.yml
    index 22a2be37..b39e36f9 100644
    --- a/.circleci/config.yml
    +++ b/.circleci/config.yml
    @@ -5,6 +5,9 @@ jobs:
         docker:
           - image: circleci/android:api-27
         environment:
    +      QEMU_AUDIO_DRV: none
    +      _JAVA_OPTIONS: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Xms2048m -Xmx4096m"
    +      GRADLE_OPTS: "-Dorg.gradle.daemon=false"
           JVM_OPTS: -Xmx3200m
           CIRCLE_ARTIFACTS: /tmp/circleci-artifacts
           CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
    @@ -26,6 +29,10 @@ jobs:
           - run: sudo mkdir -p $CIRCLE_TEST_REPORTS
           - run: sudo apt-get -y -qq install awscli
           - run: sudo mkdir -p /usr/local/android-sdk-linux/licenses
    +      - run: sdkmanager "system-images;android-24;default;armeabi-v7a"
    +      - run: |
    +          set +o pipefail
    +          yes | sdkmanager --licenses
     
           - save_cache:
                   key: v1-dep-{{ .Branch }}-{{ epoch }}
    @@ -48,13 +55,23 @@ jobs:
                   - /usr/local/android-sdk-linux/extras/android/m2repository
           - run: unset ANDROID_NDK_HOME
     
    +      - run: sudo apt-get install libpulse0
    +      - run: echo no | avdmanager create avd -n ci-android-avd -f -k "system-images;android-24;default;armeabi-v7a"
    +      - run:
    +          command: emulator -avd ci-android-avd -netdelay none -netspeed full -no-audio -no-window -no-snapshot -use-system-libs -no-boot-anim
    +          background: true
    +          timeout: 1200
    +          no_output_timeout: 2h
    +
           - run: ./gradlew :launchdarkly-android-client:assembleDebug --console=plain -PdisablePreDex
    -      - run: ./gradlew :launchdarkly-android-client:test --console=plain -PdisablePreDex
     
    -      - run: ./gradlew packageRelease --console=plain -PdisablePreDex
    +      - run: circle-android wait-for-boot
           - run:
               name: Run Tests
    -          command: ./gradlew test
    +          command: ./gradlew :launchdarkly-android-client:connectedAndroidTest --console=plain -PdisablePreDex
    +          no_output_timeout: 2h
    +
    +      - run: ./gradlew packageRelease --console=plain -PdisablePreDex
     
           - run:
               name: Save test results
    
    From 496fc69e606365fb4d9fb00d7379a4e6317f0ef2 Mon Sep 17 00:00:00 2001
    From: Gavin Whelan 
    Date: Mon, 29 Apr 2019 23:57:21 +0000
    Subject: [PATCH 087/152] [ch34533] connection status, removing guava, network
     restructuring. (#117)
    
    * Add ConnectionInformation class.
    * Remove all internal uses of Guava.
    * Update StreamUpdateProcessor to only debounce ping events.
    * Add a connection state monitor to the example app.
    ---
     example/build.gradle                          |   6 +-
     .../launchdarkly/example/MainActivity.java    |  77 ++++
     example/src/main/res/layout/activity_main.xml | 132 +++---
     launchdarkly-android-client/build.gradle      |  11 +-
     .../launchdarkly/android}/DebounceTest.java   |  30 +-
     .../launchdarkly/android/LDClientTest.java    |   6 -
     .../launchdarkly/android/LDConfigTest.java    |   8 +-
     .../launchdarkly/android/MigrationTest.java   |   5 +-
     .../android/MultiEnvironmentLDClientTest.java |   6 -
     .../launchdarkly/android/UserManagerTest.java | 376 ++++++++++++-----
     ...UserSummaryEventSharedPreferencesTest.java |  11 +-
     .../flagstore/FlagStoreManagerTest.java       |  17 +-
     .../android/flagstore/FlagStoreTest.java      |  15 +-
     .../android/flagstore/FlagTest.java           |  27 +-
     .../android/ConnectionInformation.java        |  38 ++
     .../android/ConnectionInformationState.java   |  40 ++
     .../android/ConnectivityManager.java          | 378 ++++++++++++++++++
     .../android/ConnectivityReceiver.java         |  41 +-
     .../com/launchdarkly/android/Debounce.java    |  29 +-
     .../android/EvaluationDetail.java             |   9 +-
     .../android/EvaluationReason.java             |  24 +-
     .../launchdarkly/android/EventProcessor.java  |  22 +-
     .../android/FeatureFlagFetcher.java           |   7 +-
     .../com/launchdarkly/android/Foreground.java  |  33 +-
     .../android/HttpFeatureFlagFetcher.java       |  40 +-
     .../android/LDAllFlagsListener.java           |   9 +
     .../launchdarkly/android/LDAwaitFuture.java   |  88 ++++
     .../com/launchdarkly/android/LDClient.java    | 347 ++++++++--------
     .../android/LDClientInterface.java            |  43 +-
     .../com/launchdarkly/android/LDConfig.java    |   4 +-
     .../launchdarkly/android/LDFailedFuture.java  |  40 ++
     .../com/launchdarkly/android/LDFailure.java   |  34 ++
     .../android/LDInvalidResponseCodeFailure.java |  30 ++
     .../android/LDStatusListener.java             |   8 +
     .../launchdarkly/android/LDSuccessFuture.java |  39 ++
     .../java/com/launchdarkly/android/LDUser.java |   3 +-
     .../android/LaunchDarklyException.java        |  10 +-
     .../com/launchdarkly/android/Migration.java   |  17 +-
     .../android/PollingUpdateProcessor.java       |  43 --
     .../launchdarkly/android/PollingUpdater.java  |  32 +-
     .../android/StreamUpdateProcessor.java        |  92 ++---
     .../com/launchdarkly/android/Throttler.java   |   1 +
     .../launchdarkly/android/UpdateProcessor.java |  31 --
     .../com/launchdarkly/android/UserHasher.java  |  26 +-
     .../com/launchdarkly/android/UserManager.java | 110 +++--
     .../UserSummaryEventSharedPreferences.java    |   2 +-
     .../java/com/launchdarkly/android/Util.java   |  46 ++-
     .../com/launchdarkly/android/ValueTypes.java  |  36 +-
     .../launchdarkly/android/flagstore/Flag.java  |  16 +-
     .../android/flagstore/FlagStore.java          |   7 +-
     .../android/flagstore/FlagStoreManager.java   |  15 +
     .../flagstore/StoreUpdatedListener.java       |   9 +-
     .../sharedprefs/SharedPrefsFlagStore.java     |  50 +--
     .../SharedPrefsFlagStoreManager.java          |  88 ++--
     .../launchdarkly/android/gson/GsonCache.java  |   2 +
     .../android/gson/LDFailureSerialization.java  |  49 +++
     .../android/response/DeleteFlagResponse.java  |   4 +-
     .../android/response/FlagsResponse.java       |   2 +-
     .../android/tls/ModernTLSSocketFactory.java   |   4 +-
     59 files changed, 1865 insertions(+), 860 deletions(-)
     rename {example/src/test/java/com/launchdarkly/example => launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android}/DebounceTest.java (72%)
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/ConnectionInformation.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/ConnectionInformationState.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/ConnectivityManager.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDAllFlagsListener.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDAwaitFuture.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDFailedFuture.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDFailure.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDInvalidResponseCodeFailure.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDStatusListener.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDSuccessFuture.java
     delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdateProcessor.java
     delete mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/UpdateProcessor.java
     create mode 100644 launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/LDFailureSerialization.java
    
    diff --git a/example/build.gradle b/example/build.gradle
    index 5c70916b..a9b42519 100644
    --- a/example/build.gradle
    +++ b/example/build.gradle
    @@ -42,13 +42,13 @@ android {
     
     dependencies {
         implementation fileTree(include: ['*.jar'], dir: 'libs')
    -    implementation "com.google.code.gson:gson:2.8.2"
    +    implementation 'com.google.code.gson:gson:2.8.2'
         implementation 'com.android.support:appcompat-v7:26.1.0'
         implementation project(path: ':launchdarkly-android-client')
         // Comment the previous line and uncomment this one to depend on the published artifact:
    -    //    compile 'com.launchdarkly:launchdarkly-android-client:1.0.1'
    +    //implementation 'com.launchdarkly:launchdarkly-android-client:2.7.0'
     
    -    implementation 'com.jakewharton.timber:timber:4.7.0'
    +    implementation 'com.jakewharton.timber:timber:4.7.1'
     
         testImplementation 'junit:junit:4.12'
     }
    diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java
    index 37d668e3..7463866b 100644
    --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java
    +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java
    @@ -1,6 +1,8 @@
     package com.launchdarkly.example;
     
     import android.os.Bundle;
    +import android.os.Handler;
    +import android.os.Looper;
     import android.support.annotation.Nullable;
     import android.support.v7.app.AppCompatActivity;
     import android.view.View;
    @@ -11,13 +13,21 @@
     import android.widget.Spinner;
     import android.widget.Switch;
     import android.widget.TextView;
    +import android.widget.Toast;
     
     import com.google.gson.JsonNull;
    +import com.launchdarkly.android.LDAllFlagsListener;
    +import com.launchdarkly.android.ConnectionInformation;
     import com.launchdarkly.android.FeatureFlagChangeListener;
     import com.launchdarkly.android.LDClient;
     import com.launchdarkly.android.LDConfig;
    +import com.launchdarkly.android.LDFailure;
    +import com.launchdarkly.android.LDStatusListener;
     import com.launchdarkly.android.LDUser;
     
    +import java.util.Date;
    +import java.util.List;
    +import java.util.Locale;
     import java.util.concurrent.ExecutionException;
     import java.util.concurrent.Future;
     import java.util.concurrent.TimeUnit;
    @@ -28,6 +38,32 @@
     public class MainActivity extends AppCompatActivity {
     
         private LDClient ldClient;
    +    private LDStatusListener ldStatusListener;
    +    private LDAllFlagsListener allFlagsListener;
    +
    +    private void updateStatusString(final ConnectionInformation connectionInformation) {
    +        if (Looper.myLooper() != MainActivity.this.getMainLooper()) {
    +            (new Handler(MainActivity.this.getMainLooper())).post(new Runnable() {
    +                @Override
    +                public void run() {
    +                    updateStatusString(connectionInformation);
    +                }
    +            });
    +        } else {
    +            TextView connection = MainActivity.this.findViewById(R.id.connection_status);
    +            Long lastSuccess = connectionInformation.getLastSuccessfulConnection();
    +            Long lastFailure = connectionInformation.getLastFailedConnection();
    +
    +            String result = String.format(Locale.US, "Mode: %s\nSuccess at: %s\nFailure at: %s\nFailure type: %s",
    +                    connectionInformation.getConnectionMode().toString(),
    +                    lastSuccess == null ? "Never" : new Date(lastSuccess).toString(),
    +                    lastFailure == null ? "Never" : new Date(lastFailure).toString(),
    +                    lastFailure != null ?
    +                            connectionInformation.getLastFailure().getFailureType()
    +                            : "");
    +            connection.setText(result);
    +        }
    +    }
     
         @Override
         protected void onCreate(Bundle savedInstanceState) {
    @@ -39,6 +75,7 @@ protected void onCreate(Bundle savedInstanceState) {
             setupTrackButton();
             setupIdentifyButton();
             setupOfflineSwitch();
    +        setupListeners();
     
             LDConfig ldConfig = new LDConfig.Builder()
                     .setMobileKey("MOBILE_KEY")
    @@ -52,11 +89,51 @@ protected void onCreate(Bundle savedInstanceState) {
             Future initFuture = LDClient.init(this.getApplication(), ldConfig, user);
             try {
                 ldClient = initFuture.get(10, TimeUnit.SECONDS);
    +            updateStatusString(ldClient.getConnectionInformation());
    +            ldClient.registerStatusListener(ldStatusListener);
    +            ldClient.registerAllFlagsListener(allFlagsListener);
             } catch (InterruptedException | ExecutionException | TimeoutException e) {
                 Timber.e(e, "Exception when awaiting LaunchDarkly Client initialization");
             }
         }
     
    +    private void setupListeners() {
    +        ldStatusListener = new LDStatusListener() {
    +            @Override
    +            public void onConnectionModeChanged(final ConnectionInformation connectionInformation) {
    +                updateStatusString(connectionInformation);
    +            }
    +
    +            @Override
    +            public void onInternalFailure(final LDFailure ldFailure) {
    +                new Handler(MainActivity.this.getMainLooper()).post(new Runnable() {
    +                    @Override
    +                    public void run() {
    +                        Toast.makeText(MainActivity.this, ldFailure.toString(), Toast.LENGTH_SHORT).show();
    +                    }
    +                });
    +                updateStatusString(ldClient.getConnectionInformation());
    +            }
    +        };
    +
    +        allFlagsListener = new LDAllFlagsListener() {
    +            @Override
    +            public void onChange(final List flagKey) {
    +                new Handler(MainActivity.this.getMainLooper()).post(new Runnable() {
    +                    @Override
    +                    public void run() {
    +                        StringBuilder flags = new StringBuilder("Updated flags: ");
    +                        for (String flag : flagKey) {
    +                            flags.append(flag).append(" ");
    +                        }
    +                        Toast.makeText(MainActivity.this, flags.toString(), Toast.LENGTH_SHORT).show();
    +                    }
    +                });
    +                updateStatusString(ldClient.getConnectionInformation());
    +            }
    +        };
    +    }
    +
         private void setupFlushButton() {
             Button flushButton = findViewById(R.id.flush_button);
             flushButton.setOnClickListener(new View.OnClickListener() {
    diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml
    index facf5e72..168abad2 100644
    --- a/example/src/main/res/layout/activity_main.xml
    +++ b/example/src/main/res/layout/activity_main.xml
    @@ -1,130 +1,150 @@