All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.launchdarkly.sdk.android.LDClient Maven / Gradle / Ivy

package com.launchdarkly.sdk.android;

import android.app.Application;
import android.content.Context;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.os.Build;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import com.launchdarkly.sdk.EvaluationDetail;
import com.launchdarkly.sdk.EvaluationReason;
import com.launchdarkly.sdk.LDUser;
import com.launchdarkly.sdk.LDValue;
import com.launchdarkly.sdk.UserAttribute;

import java.io.Closeable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;

import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import timber.log.Timber;

/**
 * Client for accessing LaunchDarkly's Feature Flag system. This class enforces a singleton pattern.
 * The main entry point is the {@link #init(Application, LDConfig, LDUser)} method.
 */
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 Map instances = null;

    private final Application application;
    private final LDConfig config;
    private final DefaultUserManager userManager;
    private final DefaultEventProcessor eventProcessor;
    private final ConnectivityManager connectivityManager;
    private final DiagnosticEventProcessor diagnosticEventProcessor;
    private final DiagnosticStore diagnosticStore;
    private ConnectivityReceiver connectivityReceiver;
    private final List> connectionFailureListeners =
            Collections.synchronizedList(new ArrayList<>());
    private final ExecutorService executor = Executors.newFixedThreadPool(1);

    /**
     * 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()}
     * 

* 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 * @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) { // As this is an externally facing API we should still check these, so we hide the linter // warnings //noinspection ConstantConditions if (application == null) { return new LDFailedFuture<>(new LaunchDarklyException("Client initialization requires a valid application")); } //noinspection ConstantConditions if (config == null) { return new LDFailedFuture<>(new LaunchDarklyException("Client initialization requires a valid configuration")); } //noinspection ConstantConditions if (user == null) { return new LDFailedFuture<>(new LaunchDarklyException("Client initialization requires a valid user")); } if (instances != null) { LDConfig.LOG.w("LDClient.init() was called more than once! returning primary instance."); return new LDSuccessFuture<>(instances.get(LDConfig.primaryEnvironmentName)); } if (BuildConfig.DEBUG) { Timber.plant(new Timber.DebugTree()); } Foreground.init(application); instances = new HashMap<>(); SharedPreferences instanceIdSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE); if (!instanceIdSharedPrefs.contains(INSTANCE_ID_KEY)) { String uuid = UUID.randomUUID().toString(); LDConfig.LOG.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); LDConfig.LOG.i("Using instance id: %s", instanceId); Migration.migrateWhenNeeded(application, config); final LDAwaitFuture resultFuture = new LDAwaitFuture<>(); final AtomicInteger initCounter = new AtomicInteger(config.getMobileKeys().size()); LDUtil.ResultCallback completeWhenCounterZero = new LDUtil.ResultCallback() { @Override public void onSuccess(Void result) { if (initCounter.decrementAndGet() == 0) { resultFuture.set(instances.get(LDConfig.primaryEnvironmentName)); } } @Override public void onError(Throwable e) { resultFuture.setException(e); } }; PollingUpdater.setBackgroundPollingIntervalMillis(config.getBackgroundPollingIntervalMillis()); user = customizeUser(user); for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { final LDClient instance = new LDClient(application, config, mobileKeys.getKey()); instance.userManager.setCurrentUser(user); instances.put(mobileKeys.getKey(), instance); if (instance.connectivityManager.startUp(completeWhenCounterZero)) { instance.sendEvent(new IdentifyEvent(user)); } } return resultFuture; } @VisibleForTesting static LDUser customizeUser(LDUser user) { LDUser.Builder builder = new LDUser.Builder(user); if (user.getAttribute(UserAttribute.forName("os")).isNull()) { builder.custom("os", Build.VERSION.SDK_INT); } if (user.getAttribute(UserAttribute.forName("device")).isNull()) { builder.custom("device", Build.MODEL + " " + Build.PRODUCT); } String key = user.getKey(); if (key == null || key.equals("")) { LDConfig.LOG.i("User was created with null/empty key. Using device-unique anonymous user key: %s", LDClient.getInstanceId()); builder.key(LDClient.getInstanceId()); builder.anonymous(true); } return builder.build(); } /** * Initializes the singleton instance and blocks for up to startWaitSeconds seconds * until the client has been initialized. If the client does not initialize within * startWaitSeconds seconds, it is returned anyway and can be used, but may not * have fetched the most recent feature flag values. * * @param application 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) { LDConfig.LOG.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) { LDConfig.LOG.e(e, "Exception during Client initialization"); } catch (TimeoutException e) { LDConfig.LOG.w("Client did not successfully initialize within %s seconds. It could be taking longer than expected to start up", startWaitSeconds); } return instances.get(LDConfig.primaryEnvironmentName); } /** * @return the singleton instance. * @throws LaunchDarklyException if {@link #init(Application, LDConfig, LDUser)} has not been called. */ public static LDClient get() throws LaunchDarklyException { if (instances == null) { LDConfig.LOG.e("LDClient.get() was called before init()!"); throw new LaunchDarklyException("LDClient.get() was called before init()!"); } return instances.get(LDConfig.primaryEnvironmentName); } /** * @return the singleton instance for the environment associated with the given name. * @param keyName The name to lookup the instance by. * @throws LaunchDarklyException if {@link #init(Application, LDConfig, LDUser)} has not been called. */ @SuppressWarnings("WeakerAccess") public static LDClient getForMobileKey(String keyName) throws LaunchDarklyException { if (instances == null) { LDConfig.LOG.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); } @VisibleForTesting protected LDClient(final Application application, @NonNull final LDConfig config) { this(application, config, LDConfig.primaryEnvironmentName); } @VisibleForTesting protected LDClient(final Application application, @NonNull final LDConfig config, final String environmentName) { LDConfig.LOG.i("Creating LaunchDarkly client. Version: %s", BuildConfig.VERSION_NAME); this.config = config; this.application = application; String sdkKey = config.getMobileKeys().get(environmentName); FeatureFetcher fetcher = HttpFeatureFlagFetcher.newInstance(application, config, environmentName); OkHttpClient sharedEventClient = makeSharedEventClient(); if (config.getDiagnosticOptOut()) { this.diagnosticStore = null; this.diagnosticEventProcessor = null; } else { this.diagnosticStore = new DiagnosticStore(application, sdkKey); this.diagnosticEventProcessor = new DiagnosticEventProcessor(config, environmentName, diagnosticStore, application, sharedEventClient); } this.userManager = DefaultUserManager.newInstance(application, fetcher, environmentName, sdkKey, config.getMaxCachedUsers()); eventProcessor = new DefaultEventProcessor(application, config, userManager.getSummaryEventStore(), environmentName, diagnosticStore, sharedEventClient); connectivityManager = new ConnectivityManager(application, config, eventProcessor, userManager, environmentName, diagnosticStore); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { connectivityReceiver = new ConnectivityReceiver(); IntentFilter filter = new IntentFilter(ConnectivityReceiver.CONNECTIVITY_CHANGE); application.registerReceiver(connectivityReceiver, filter); } } private OkHttpClient makeSharedEventClient() { return new OkHttpClient.Builder() .connectionPool(new ConnectionPool(1, config.getEventsFlushIntervalMillis() * 2, TimeUnit.MILLISECONDS)) .connectTimeout(config.getConnectionTimeoutMillis(), TimeUnit.MILLISECONDS) .retryOnConnectionFailure(true) .build(); } @Override public void trackMetric(String eventName, LDValue data, double metricValue) { trackInternal(eventName, data, metricValue); } @Override public void trackData(String eventName, LDValue data) { trackInternal(eventName, data, null); } @Override public void track(String eventName) { trackInternal(eventName, null, null); } private void trackInternal(String eventName, LDValue data, Double metricValue) { sendEvent(new CustomEvent(eventName, userManager.getCurrentUser(), data, metricValue, config.inlineUsersInEvents())); } @Override public Future identify(LDUser user) { if (user == null) { return new LDFailedFuture<>(new LaunchDarklyException("User cannot be null")); } if (user.getKey() == null) { LDConfig.LOG.w("identify called with null user or null user key!"); } return LDClient.identifyInstances(customizeUser(user)); } private synchronized void identifyInternal(@NonNull LDUser user, LDUtil.ResultCallback onCompleteListener) { if (!config.isAutoAliasingOptOut()) { LDUser previousUser = userManager.getCurrentUser(); if (Event.userContextKind(previousUser).equals("anonymousUser") && Event.userContextKind(user).equals("user")) { sendEvent(new AliasEvent(user, previousUser)); } } userManager.setCurrentUser(user); connectivityManager.reloadUser(onCompleteListener); sendEvent(new IdentifyEvent(user)); } private static synchronized Future identifyInstances(@NonNull LDUser user) { final LDAwaitFuture resultFuture = new LDAwaitFuture<>(); final AtomicInteger identifyCounter = new AtomicInteger(instances.size()); LDUtil.ResultCallback completeWhenCounterZero = new LDUtil.ResultCallback() { @Override public void onSuccess(Void result) { if (identifyCounter.decrementAndGet() == 0) { resultFuture.set(null); } } @Override public void onError(Throwable e) { resultFuture.setException(e); } }; for (LDClient client : instances.values()) { client.identifyInternal(user, completeWhenCounterZero); } return resultFuture; } @Override public Map allFlags() { Collection allFlags = userManager.getCurrentUserFlagStore().getAllFlags(); HashMap flagValues = new HashMap<>(); for (Flag flag: allFlags) { flagValues.put(flag.getKey(), flag.getValue()); } return flagValues; } @Override public boolean boolVariation(@NonNull String key, boolean defaultValue) { return variationDetailInternal(key, LDValue.of(defaultValue), true, false).getValue().booleanValue(); } @Override public EvaluationDetail boolVariationDetail(@NonNull String key, boolean defaultValue) { return convertDetailType(variationDetailInternal(key, LDValue.of(defaultValue), true, true), LDValue.Convert.Boolean); } @Override public int intVariation(@NonNull String key, int defaultValue) { return variationDetailInternal(key, LDValue.of(defaultValue), true, false).getValue().intValue(); } @Override public EvaluationDetail intVariationDetail(@NonNull String key, int defaultValue) { return convertDetailType(variationDetailInternal(key, LDValue.of(defaultValue), true, true), LDValue.Convert.Integer); } @Override public double doubleVariation(String flagKey, double defaultValue) { return variationDetailInternal(flagKey, LDValue.of(defaultValue), true, false).getValue().doubleValue(); } @Override public EvaluationDetail doubleVariationDetail(String flagKey, double defaultValue) { return convertDetailType(variationDetailInternal(flagKey, LDValue.of(defaultValue), true, true), LDValue.Convert.Double); } @Override public String stringVariation(@NonNull String key, String defaultValue) { return variationDetailInternal(key, LDValue.of(defaultValue), true, false).getValue().stringValue(); } @Override public EvaluationDetail stringVariationDetail(@NonNull String key, String defaultValue) { return convertDetailType(variationDetailInternal(key, LDValue.of(defaultValue), true, true), LDValue.Convert.String); } @Override public LDValue jsonValueVariation(@NonNull String key, LDValue defaultValue) { return variationDetailInternal(key, LDValue.normalize(defaultValue), false, false).getValue(); } @Override public EvaluationDetail jsonValueVariationDetail(@NonNull String key, LDValue defaultValue) { return variationDetailInternal(key, LDValue.normalize(defaultValue), false, true); } private EvaluationDetail convertDetailType(EvaluationDetail detail, LDValue.Converter converter) { return EvaluationDetail.fromValue(converter.toType(detail.getValue()), detail.getVariationIndex(), detail.getReason()); } private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason) { Flag flag = userManager.getCurrentUserFlagStore().getFlag(key); EvaluationDetail result; LDValue value = defaultValue; if (flag == null) { LDConfig.LOG.i("Unknown feature flag \"%s\"; returning default value", key); result = EvaluationDetail.fromValue(defaultValue, EvaluationDetail.NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); } else { value = flag.getValue(); if (value.isNull()) { LDConfig.LOG.w("Feature flag \"%s\" retrieved with no value; returning default value", key); value = defaultValue; int variation = flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); result = EvaluationDetail.fromValue(defaultValue, variation, flag.getReason()); } else if (checkType && !defaultValue.isNull() && value.getType() != defaultValue.getType()) { LDConfig.LOG.w("Feature flag \"%s\" with type %s retrieved as %s; returning default value", key, value.getType(), defaultValue.getType()); value = defaultValue; result = EvaluationDetail.fromValue(defaultValue, EvaluationDetail.NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); } else { result = EvaluationDetail.fromValue(value, flag.getVariation(), flag.getReason()); } sendFlagRequestEvent(key, flag, value, defaultValue, flag.isTrackReason() | needsReason ? result.getReason() : null); } LDConfig.LOG.d("returning variation: %s flagKey: %s user key: %s", result, key, userManager.getCurrentUser().getKey()); updateSummaryEvents(key, flag, value, defaultValue); return result; } /** * Closes the client. This should only be called at the end of a client's lifecycle. * * @throws IOException declared by the Closeable interface, but will not be thrown by the client */ @Override public void close() throws IOException { LDClient.closeInstances(); } private void closeInternal() { connectivityManager.shutdown(); eventProcessor.close(); if (diagnosticEventProcessor != null) { diagnosticEventProcessor.close(); } if (connectivityReceiver != null) { application.unregisterReceiver(connectivityReceiver); connectivityReceiver = null; } } private static void closeInstances() { for (LDClient client : instances.values()) { client.closeInternal(); } } @Override public void flush() { LDClient.flushInstances(); } private void flushInternal() { eventProcessor.flush(); } private static void flushInstances() { for (LDClient client : instances.values()) { client.flushInternal(); } } @VisibleForTesting void blockingFlush() { eventProcessor.blockingFlush(); } @Override public boolean isInitialized() { return connectivityManager.isOffline() || connectivityManager.isInitialized(); } @Override public boolean isOffline() { return connectivityManager.isOffline(); } @Override public synchronized void setOffline() { LDClient.setInstancesOffline(); } private synchronized void setOfflineInternal() { connectivityManager.setOffline(); setDiagnosticsOnline(false); } private synchronized static void setInstancesOffline() { for (LDClient client : instances.values()) { client.setOfflineInternal(); } } @Override public synchronized void setOnline() { setOnlineStatusInstances(); } private void setOnlineStatusInternal() { connectivityManager.setOnline(); setDiagnosticsOnline(true); } private void setDiagnosticsOnline(boolean isOnline) { if (diagnosticEventProcessor != null) { if (isOnline) { diagnosticEventProcessor.startScheduler(); } else { diagnosticEventProcessor.stopScheduler(); } } } private static void setOnlineStatusInstances() { for (LDClient client : instances.values()) { client.setOnlineStatusInternal(); } } @Override public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { userManager.registerListener(flagKey, listener); } @Override public void unregisterFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { userManager.unregisterListener(flagKey, listener); } @Override public boolean isDisableBackgroundPolling() { return config.isDisableBackgroundPolling(); } public ConnectionInformation getConnectionInformation() { return connectivityManager.getConnectionInformation(); } public void registerStatusListener(LDStatusListener LDStatusListener) { if (LDStatusListener == null) { return; } synchronized (connectionFailureListeners) { connectionFailureListeners.add(new WeakReference<>(LDStatusListener)); } } public void unregisterStatusListener(LDStatusListener LDStatusListener) { if (LDStatusListener == null) { return; } synchronized (connectionFailureListeners) { Iterator> iter = connectionFailureListeners.iterator(); while (iter.hasNext()) { LDStatusListener mListener = iter.next().get(); if (mListener == null || mListener == LDStatusListener) { iter.remove(); } } } } public void registerAllFlagsListener(LDAllFlagsListener allFlagsListener) { userManager.registerAllFlagsListener(allFlagsListener); } public void unregisterAllFlagsListener(LDAllFlagsListener allFlagsListener) { userManager.unregisterAllFlagsListener(allFlagsListener); } /** * Alias associates two users for analytics purposes. * * @param user The first user * @param previousUser The second user */ public void alias(LDUser user, LDUser previousUser) { sendEvent(new AliasEvent(customizeUser(user), customizeUser(previousUser))); } private void triggerPoll() { connectivityManager.triggerPoll(); } void updateListenersConnectionModeChanged(final ConnectionInformation connectionInformation) { synchronized (connectionFailureListeners) { Iterator> iter = connectionFailureListeners.iterator(); while (iter.hasNext()) { final LDStatusListener mListener = iter.next().get(); if (mListener == null) { iter.remove(); } else { executor.submit(() -> mListener.onConnectionModeChanged(connectionInformation)); } } } } void updateListenersOnFailure(final LDFailure ldFailure) { synchronized (connectionFailureListeners) { Iterator> iter = connectionFailureListeners.iterator(); while (iter.hasNext()) { final LDStatusListener mListener = iter.next().get(); if (mListener == null) { iter.remove(); } else { executor.submit(() -> mListener.onInternalFailure(ldFailure)); } } } } @Override public String getVersion() { return BuildConfig.VERSION_NAME; } static String getInstanceId() { return instanceId; } private void onNetworkConnectivityChange(boolean connectedToInternet) { setDiagnosticsOnline(connectedToInternet); connectivityManager.onNetworkConnectivityChange(connectedToInternet); } private void sendFlagRequestEvent(String flagKey, Flag flag, LDValue value, LDValue defaultValue, EvaluationReason reason) { Integer version = flag.getVersionForEvents(); Integer variation = flag.getVariation(); if (flag.getTrackEvents()) { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser(), value, defaultValue, version, variation, reason, config.inlineUsersInEvents(), false)); } else { Long debugEventsUntilDate = flag.getDebugEventsUntilDate(); if (debugEventsUntilDate != null) { long serverTimeMs = eventProcessor.getCurrentTimeMs(); if (debugEventsUntilDate > System.currentTimeMillis() && debugEventsUntilDate > serverTimeMs) { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser(), value, defaultValue, version, variation, reason, false, true)); } } } } private void sendEvent(Event event) { if (!connectivityManager.isOffline()) { boolean processed = eventProcessor.sendEvent(event); if (!processed) { LDConfig.LOG.w("Exceeded event queue capacity. Increase capacity to avoid dropping events."); if (diagnosticStore != null) { diagnosticStore.incrementDroppedEventCount(); } } } } /** * Updates the internal representation of a summary event, either adding a new field or updating the existing count. * 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 defaultValue The default value used in the evaluation of the flagKey */ private void updateSummaryEvents(String flagKey, Flag flag, LDValue result, LDValue defaultValue) { result = LDValue.normalize(result); defaultValue = LDValue.normalize(defaultValue); Integer version = flag == null ? null : flag.getVersionForEvents(); Integer variation = flag == null ? null : flag.getVariation(); userManager.getSummaryEventStore().addOrUpdateEvent(flagKey, result, defaultValue, version, variation); } static synchronized void triggerPollInstances() { if (instances == null) { LDConfig.LOG.w("Cannot perform poll when LDClient has not been initialized!"); return; } for (LDClient instance : instances.values()) { instance.triggerPoll(); } } static synchronized void onNetworkConnectivityChangeInstances(boolean network) { if (instances == null) { LDConfig.LOG.e("Tried to update LDClients with network connectivity status, but LDClient has not yet been initialized."); return; } for (LDClient instance : instances.values()) { instance.onNetworkConnectivityChange(network); } } @VisibleForTesting SummaryEventStore getSummaryEventStore() { return userManager.getSummaryEventStore(); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy