diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java index b19c204d..e7943679 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java @@ -6,6 +6,7 @@ import com.launchdarkly.logging.LDLogger; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -15,12 +16,22 @@ * Standard implementation of {@link TaskExecutor} for use in the Android environment. Besides * enforcing correct thread usage, this class also ensures that any unchecked exceptions thrown by * asynchronous tasks are caught and logged. + *

+ * Internally uses two thread pools: + *

*/ final class AndroidTaskExecutor implements TaskExecutor { private final Application application; private final Handler handler; private final LDLogger logger; - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + private final ExecutorService parallelExecutor = Executors.newCachedThreadPool(); AndroidTaskExecutor(Application application, LDLogger logger) { this.application = application; @@ -39,18 +50,30 @@ public void executeOnMainThread(Runnable action) { @Override public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { - return executor.schedule(wrapActionWithErrorHandling(action), delayMillis, TimeUnit.MILLISECONDS); + return scheduledExecutor.schedule(wrapActionWithErrorHandling(action), delayMillis, TimeUnit.MILLISECONDS); } @Override public ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis) { - return executor.scheduleAtFixedRate(wrapActionWithErrorHandling(action), + return scheduledExecutor.scheduleAtFixedRate(wrapActionWithErrorHandling(action), initialDelayMillis, intervalMillis, TimeUnit.MILLISECONDS); } + @Override + public void startTask(Runnable action) { + parallelExecutor.execute(wrapActionWithErrorHandling(action)); + } + + @Override + public ScheduledFuture startTaskWithFixedDelay(Runnable action, long initialDelayMillis, long delayMillis) { + return scheduledExecutor.scheduleWithFixedDelay(wrapActionWithErrorHandling(action), + initialDelayMillis, delayMillis, TimeUnit.MILLISECONDS); + } + @Override public void close() { - executor.shutdownNow(); + scheduledExecutor.shutdownNow(); + parallelExecutor.shutdownNow(); } private Runnable wrapActionWithErrorHandling(Runnable action) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 85066fbe..948b56f6 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -4,10 +4,13 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; -import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.internal.events.DiagnosticStore; +import androidx.annotation.Nullable; + /** * This package-private subclass of {@link ClientContext} contains additional non-public SDK objects * that may be used by our internal components. @@ -33,7 +36,10 @@ final class ClientContextImpl extends ClientContext { private final PlatformState platformState; private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + @Nullable + private final TransactionalDataStore transactionalDataStore; + /** Used by FDv1 code paths that do not need a {@link TransactionalDataStore}. */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -41,6 +47,23 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData + ) { + this(base, diagnosticStore, fetcher, platformState, taskExecutor, perEnvironmentData, null); + } + + /** + * Used by FDv2 code paths. The {@code transactionalDataStore} is needed by + * {@link FDv2DataSourceBuilder} to create {@link SelectorSourceFacade} instances + * that provide selector state to initializers and synchronizers. + */ + ClientContextImpl( + ClientContext base, + DiagnosticStore diagnosticStore, + FeatureFetcher fetcher, + PlatformState platformState, + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, + @Nullable TransactionalDataStore transactionalDataStore ) { super(base); this.diagnosticStore = diagnosticStore; @@ -48,6 +71,7 @@ final class ClientContextImpl extends ClientContext { this.platformState = platformState; this.taskExecutor = taskExecutor; this.perEnvironmentData = perEnvironmentData; + this.transactionalDataStore = transactionalDataStore; } static ClientContextImpl fromConfig( @@ -95,12 +119,30 @@ public static ClientContextImpl get(ClientContext context) { return new ClientContextImpl(context, null, null, null, null, null); } + /** Creates a context for FDv1 data sources that do not need a {@link TransactionalDataStore}. */ public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, LDContext newEvaluationContext, boolean newInBackground, Boolean previouslyInBackground + ) { + return forDataSource(baseClientContext, dataSourceUpdateSink, newEvaluationContext, + newInBackground, previouslyInBackground, null); + } + + /** + * Creates a context for data sources, optionally including a {@link TransactionalDataStore}. + * FDv2 data sources require the store so that {@link FDv2DataSourceBuilder} can provide + * selector state to initializers and synchronizers via {@link SelectorSourceFacade}. + */ + public static ClientContextImpl forDataSource( + ClientContext baseClientContext, + DataSourceUpdateSink dataSourceUpdateSink, + LDContext newEvaluationContext, + boolean newInBackground, + Boolean previouslyInBackground, + @Nullable TransactionalDataStore transactionalDataStore ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); return new ClientContextImpl( @@ -123,7 +165,8 @@ public static ClientContextImpl forDataSource( baseContextImpl.getFetcher(), baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), - baseContextImpl.getPerEnvironmentData() + baseContextImpl.getPerEnvironmentData(), + transactionalDataStore ); } @@ -139,7 +182,8 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.fetcher, this.platformState, this.taskExecutor, - this.perEnvironmentData + this.perEnvironmentData, + this.transactionalDataStore ); } @@ -163,6 +207,11 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + @Nullable + public TransactionalDataStore getTransactionalDataStore() { + return transactionalDataStore; + } + private static T throwExceptionIfNull(T o) { if (o == null) { throw new IllegalStateException( diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java new file mode 100644 index 00000000..a256ec96 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.android; + +/** + * Enumerates the built-in FDv2 connection modes. Each mode maps to a + * {@link ModeDefinition} that specifies which initializers and synchronizers + * are active when the SDK is operating in that mode. + *

+ * Not to be confused with {@link ConnectionInformation.ConnectionMode}, which + * is the public FDv1 enum representing the SDK's current connection state + * (e.g. POLLING, STREAMING, SET_OFFLINE). This class is an internal FDv2 + * concept describing the desired data-acquisition pipeline. + *

+ * This is a closed enum — custom connection modes (spec 5.3.5 TBD) are not + * supported in this release. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + * @see ModeResolutionTable + */ +final class ConnectionMode { + + static final ConnectionMode STREAMING = new ConnectionMode("streaming"); + static final ConnectionMode POLLING = new ConnectionMode("polling"); + static final ConnectionMode OFFLINE = new ConnectionMode("offline"); + static final ConnectionMode ONE_SHOT = new ConnectionMode("one-shot"); + static final ConnectionMode BACKGROUND = new ConnectionMode("background"); + + private final String name; + + private ConnectionMode(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 22b09e23..d15dda4b 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -6,7 +6,6 @@ import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.subsystems.Callback; -import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; @@ -15,7 +14,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.EventProcessor; -import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -25,8 +24,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import static com.launchdarkly.sdk.android.ConnectionInformation.ConnectionMode; - class ConnectivityManager { // Implementation notes: // @@ -60,6 +57,7 @@ class ConnectivityManager { private final ConnectionInformationState connectionInformation; private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; private final EventProcessor eventProcessor; + private final TransactionalDataStore transactionalDataStore; private final PlatformState.ForegroundChangeListener foregroundListener; private final PlatformState.ConnectivityChangeListener connectivityChangeListener; private final TaskExecutor taskExecutor; @@ -74,6 +72,8 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; + private final boolean useFDv2ModeResolution; + private volatile ConnectionMode currentFDv2Mode; // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource. // This has two purposes: 1. to decouple the data source implementation from the details of how @@ -105,7 +105,7 @@ public void apply(@NonNull LDContext context, @NonNull ChangeSet { - updateDataSource(false, LDUtil.noOpCallback()); - }; + connectivityChangeListener = networkAvailable -> handleModeStateChange(); platformState.addConnectivityChangeListener(connectivityChangeListener); - foregroundListener = foreground -> { - DataSource dataSource = currentDataSource.get(); - if (dataSource == null || dataSource.needsRefresh(!foreground, - currentContext.get())) { - updateDataSource(true, LDUtil.noOpCallback()); - } - }; + foregroundListener = foreground -> handleModeStateChange(); platformState.addForegroundChangeListener(foregroundListener); } @@ -180,6 +174,7 @@ void switchToContext(@NonNull LDContext context, @NonNull Callback onCompl onCompletion.onSuccess(null); } else { if (dataSource == null || dataSource.needsRefresh(!platformState.isForeground(), context)) { + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); updateDataSource(true, onCompletion); } else { onCompletion.onSuccess(null); @@ -195,25 +190,71 @@ private synchronized boolean updateDataSource( return false; } + DataSource existingDataSource = currentDataSource.get(); + boolean isFDv2ModeSwitch = false; + + // FDv2 path: resolve mode for both startup (mustReinitializeDataSource=true) and + // state-change (mustReinitializeDataSource=false) cases. + if (useFDv2ModeResolution) { + ConnectionMode newMode = resolveMode(); + if (!mustReinitializeDataSource) { + // State-change path: check for no-op or equivalent config before rebuilding. + if (newMode == currentFDv2Mode) { + onCompletion.onSuccess(null); + return false; + } + // CSFDV2 5.3.8: retain active data source if old and new modes have equivalent config. + // ModeDefinition currently relies on Object.equals (reference equality) because + // makeDefaultModeTable() reuses the same instance for modes that share identical + // configuration. + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + ModeDefinition oldDef = fdv2Builder.getModeDefinition(currentFDv2Mode); + ModeDefinition newDef = fdv2Builder.getModeDefinition(newMode); + if (oldDef != null && oldDef.equals(newDef)) { + currentFDv2Mode = newMode; + onCompletion.onSuccess(null); + return false; + } + isFDv2ModeSwitch = true; + mustReinitializeDataSource = true; + } + currentFDv2Mode = newMode; + } + + // Check whether the existing data source needs a rebuild (e.g. evaluation context changed). + if (!mustReinitializeDataSource && existingDataSource != null) { + boolean inBackground = !platformState.isForeground(); + if (existingDataSource.needsRefresh(inBackground, currentContext.get())) { + mustReinitializeDataSource = true; + } + } + boolean forceOffline = forcedOffline.get(); boolean networkEnabled = platformState.isNetworkAvailable(); boolean inBackground = !platformState.isForeground(); LDContext context = currentContext.get(); - eventProcessor.setOffline(forceOffline || !networkEnabled); - eventProcessor.setInBackground(inBackground); - boolean shouldStopExistingDataSource = true, shouldStartDataSourceIfStopped = false; - if (forceOffline) { + if (useFDv2ModeResolution) { + // FDv2 mode resolution already accounts for offline/background states via + // the ModeResolutionTable, so we always rebuild when the mode changed. + // Note: unlike FDv1's forceOffline/noNetwork branches above, initialized=true + // is not set here eagerly — it is set in the dataSource.start() callback below. + // For OFFLINE mode this creates a brief async gap (one executor task) before + // isInitialized() returns true, but the OFFLINE data source fires its callback + // nearly instantaneously since it has no initializers or synchronizers. + shouldStopExistingDataSource = mustReinitializeDataSource; + shouldStartDataSourceIfStopped = true; + } else if (forceOffline) { logger.debug("Initialized in offline mode"); initialized = true; - dataSourceUpdateSink.setStatus(ConnectionMode.SET_OFFLINE, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.SET_OFFLINE, null); } else if (!networkEnabled) { - dataSourceUpdateSink.setStatus(ConnectionMode.OFFLINE, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.OFFLINE, null); } else if (inBackground && backgroundUpdatingDisabled) { - dataSourceUpdateSink.setStatus(ConnectionMode.BACKGROUND_DISABLED, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.BACKGROUND_DISABLED, null); } else { shouldStopExistingDataSource = mustReinitializeDataSource; shouldStartDataSourceIfStopped = true; @@ -237,8 +278,15 @@ private synchronized boolean updateDataSource( dataSourceUpdateSink, context, inBackground, - previouslyInBackground.get() + previouslyInBackground.get(), + transactionalDataStore ); + + if (useFDv2ModeResolution) { + // CONNMODE 2.0.1: mode switches only transition synchronizers, not initializers. + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); + } + DataSource dataSource = dataSourceFactory.build(clientContext); currentDataSource.set(dataSource); previouslyInBackground.set(Boolean.valueOf(inBackground)); @@ -247,16 +295,12 @@ private synchronized boolean updateDataSource( @Override public void onSuccess(Boolean result) { initialized = true; - // passing the current connection mode since we don't want to change the mode, just trigger - // the logic to update the last connection success. updateConnectionInfoForSuccess(connectionInformation.getConnectionMode()); onCompletion.onSuccess(null); } @Override public void onError(Throwable error) { - // passing the current connection mode since we don't want to change the mode, just trigger - // the logic to update the last connection failure. updateConnectionInfoForError(connectionInformation.getConnectionMode(), error); onCompletion.onSuccess(null); } @@ -293,7 +337,7 @@ void unregisterStatusListener(LDStatusListener LDStatusListener) { } } - private void updateConnectionInfoForSuccess(ConnectionMode connectionMode) { + private void updateConnectionInfoForSuccess(ConnectionInformation.ConnectionMode connectionMode) { boolean updated = false; if (connectionInformation.getConnectionMode() != connectionMode) { connectionInformation.setConnectionMode(connectionMode); @@ -318,7 +362,7 @@ private void updateConnectionInfoForSuccess(ConnectionMode connectionMode) { } } - private void updateConnectionInfoForError(ConnectionMode connectionMode, Throwable error) { + private void updateConnectionInfoForError(ConnectionInformation.ConnectionMode connectionMode, Throwable error) { LDFailure failure = null; if (error != null) { if (error instanceof LDFailure) { @@ -403,6 +447,8 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return false; } initialized = false; + + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); } @@ -425,7 +471,7 @@ void shutDown() { void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { - updateDataSource(false, LDUtil.noOpCallback()); + handleModeStateChange(); } } @@ -433,6 +479,42 @@ boolean isForcedOffline() { return forcedOffline.get(); } + private void updateEventProcessor(boolean forceOffline, boolean networkAvailable, boolean foreground) { + eventProcessor.setOffline(forceOffline || !networkAvailable); + eventProcessor.setInBackground(!foreground); + } + + /** + * Unified handler for all platform/configuration state changes (foreground, connectivity, + * force-offline). Snapshots the current state once, updates the event processor, then + * routes to the appropriate data source update path. + */ + private synchronized void handleModeStateChange() { + boolean forceOffline = forcedOffline.get(); + boolean networkAvailable = platformState.isNetworkAvailable(); + boolean foreground = platformState.isForeground(); + + updateEventProcessor(forceOffline, networkAvailable, foreground); + updateDataSource(false, LDUtil.noOpCallback()); + } + + /** + * Resolves the current platform state to a {@link ConnectionMode} via the + * {@link ModeResolutionTable}. Force-offline is handled as a short-circuit + * so that {@link ModeState} faithfully represents actual platform state. + */ + private ConnectionMode resolveMode() { + if (forcedOffline.get()) { + return ConnectionMode.OFFLINE; + } + ModeState state = new ModeState( + platformState.isForeground(), + platformState.isNetworkAvailable(), + backgroundUpdatingDisabled + ); + return ModeResolutionTable.MOBILE.resolve(state); + } + synchronized ConnectionInformation getConnectionInformation() { return connectionInformation; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 82406356..2ec44d9d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -7,21 +7,20 @@ import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; -import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DataSourceState; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import java.util.ArrayList; -import java.util.Map; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -45,12 +44,11 @@ public interface DataSourceFactory { private final SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; - private final ScheduledExecutorService sharedExecutor; + private final TaskExecutor taskExecutor; private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean startCompleted = new AtomicBoolean(false); private final AtomicBoolean stopped = new AtomicBoolean(false); - /** Result of the first start (null = not yet completed). Used so second start() gets the same result. */ private volatile Boolean startResult = null; private volatile Throwable startError = null; @@ -60,17 +58,17 @@ public interface DataSourceFactory { /** * Convenience constructor using default fallback and recovery timeouts. * See {@link #FDv2DataSource(LDContext, List, List, DataSourceUpdateSinkV2, - * ScheduledExecutorService, LDLogger, long, long)} for parameter documentation. + * TaskExecutor, LDLogger, long, long)} for parameter documentation. */ FDv2DataSource( @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, + @NonNull TaskExecutor taskExecutor, @NonNull LDLogger logger ) { - this(evaluationContext, initializers, synchronizers, dataSourceUpdateSink, sharedExecutor, logger, + this(evaluationContext, initializers, synchronizers, dataSourceUpdateSink, taskExecutor, logger, FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); } @@ -80,8 +78,7 @@ public interface DataSourceFactory { * @param initializers factories for one-shot initializers, tried in order * @param synchronizers factories for recurring synchronizers, tried in order * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks; must have at least - * 2 threads available for this data source to run properly. + * @param taskExecutor task executor used for internal background tasks * @param logger logger * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back to the * next synchronizer @@ -93,7 +90,7 @@ public interface DataSourceFactory { @NonNull List> initializers, @NonNull List> synchronizers, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, + @NonNull TaskExecutor taskExecutor, @NonNull LDLogger logger, long fallbackTimeoutSeconds, long recoveryTimeoutSeconds @@ -108,7 +105,7 @@ public interface DataSourceFactory { this.sourceManager = new SourceManager(synchronizerFactoriesWithState, new ArrayList<>(initializers)); this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; - this.sharedExecutor = sharedExecutor; + this.taskExecutor = taskExecutor; } @Override @@ -138,7 +135,7 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; - sharedExecutor.execute(() -> { + taskExecutor.startTask(() -> { try { if (!sourceManager.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); @@ -210,11 +207,18 @@ private void tryCompleteStart(boolean success, Throwable error) { public void stop(@NonNull Callback completionCallback) { stopped.set(true); sourceManager.close(); - // Caller owns sharedExecutor; we do not shut it down. + // Caller owns taskExecutor; we do not shut it down. dataSourceUpdateSink.setStatus(DataSourceState.OFF, null); completionCallback.onSuccess(null); } + @Override + public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { + // FDv2 background/foreground transitions are handled externally by ConnectivityManager + // via teardown/rebuild, so only request a rebuild when the evaluation context changes. + return !evaluationContext.equals(newEvaluationContext); + } + private void runInitializers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink @@ -287,9 +291,9 @@ private List getConditions(int synchronizerC return Collections.emptyList(); } List list = new ArrayList<>(); - list.add(new FDv2DataSourceConditions.FallbackCondition(sharedExecutor, fallbackTimeoutSeconds)); + list.add(new FDv2DataSourceConditions.FallbackCondition(taskExecutor, fallbackTimeoutSeconds)); if (!isPrime) { - list.add(new FDv2DataSourceConditions.RecoveryCondition(sharedExecutor, recoveryTimeoutSeconds)); + list.add(new FDv2DataSourceConditions.RecoveryCondition(taskExecutor, recoveryTimeoutSeconds)); } return list; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java new file mode 100644 index 00000000..19ae4c4d --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -0,0 +1,242 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; +import com.launchdarkly.sdk.internal.http.HttpProperties; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Builds an {@link FDv2DataSource} by resolving {@link ComponentConfigurer} factories + * into zero-arg {@link FDv2DataSource.DataSourceFactory} instances. The builder is the + * sole owner of mode resolution; {@link ConnectivityManager} configures the target mode + * via {@link #setActiveMode} before calling the standard {@link #build}. + *

+ * Package-private — not part of the public SDK API. + */ +class FDv2DataSourceBuilder implements ComponentConfigurer { + + private Map modeTable; + private final ConnectionMode startingMode; + + private ConnectionMode activeMode; + private boolean includeInitializers = true; // false during mode switches to skip initializers (CONNMODE 2.0.1) + + FDv2DataSourceBuilder() { + this.modeTable = null; // built lazily in build() + this.startingMode = ConnectionMode.STREAMING; + } + + private Map makeDefaultModeTable() { + ComponentConfigurer pollingInitializer = ctx -> { + DataSourceSetup s = new DataSourceSetup(ctx); + FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); + return new FDv2PollingInitializer(requestor, s.selectorSource, + ClientContextImpl.get(ctx).getTaskExecutor(), ctx.getBaseLogger()); + }; + + ComponentConfigurer pollingSynchronizer = ctx -> { + DataSourceSetup s = new DataSourceSetup(ctx); + FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); + return new FDv2PollingSynchronizer(requestor, s.selectorSource, + ClientContextImpl.get(ctx).getTaskExecutor(), + 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + ComponentConfigurer streamingSynchronizer = ctx -> { + DataSourceSetup s = new DataSourceSetup(ctx); + // The streaming synchronizer uses a polling requestor for its internal + // polling fallback (e.g. when the stream cannot be established). + FDv2Requestor pollingRequestor = makePollingRequestor(ctx, s.httpProps); + URI streamBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getStreamingBaseUri(), + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + "streaming", ctx.getBaseLogger()); + return new FDv2StreamingSynchronizer( + ctx.getEvaluationContext(), s.selectorSource, streamBase, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + pollingRequestor, + StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, + ctx.isEvaluationReasons(), ctx.getHttp().isUseReport(), + s.httpProps, ClientContextImpl.get(ctx).getTaskExecutor(), + ctx.getBaseLogger(), null); + }; + + ComponentConfigurer backgroundPollingSynchronizer = ctx -> { + DataSourceSetup s = new DataSourceSetup(ctx); + FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); + return new FDv2PollingSynchronizer(requestor, s.selectorSource, + ClientContextImpl.get(ctx).getTaskExecutor(), + 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + Map table = new LinkedHashMap<>(); + table.put(ConnectionMode.STREAMING, new ModeDefinition( + // TODO: cacheInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer), + Arrays.asList(streamingSynchronizer, pollingSynchronizer) + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(pollingSynchronizer) + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.>emptyList() + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + // TODO: cacheInitializer and streamingInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), + Collections.>emptyList() + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(backgroundPollingSynchronizer) + )); + return table; + } + + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode + ) { + this.modeTable = modeTable; + this.startingMode = startingMode; + } + + @NonNull + ConnectionMode getStartingMode() { + return startingMode; + } + + /** + * Configures the mode to build for and whether to include initializers. + * Called by {@link ConnectivityManager} before each {@link #build} call. + * + * @param mode the target connection mode + * @param includeInitializers true for initial startup / identify, false for mode switches + * (per CONNMODE 2.0.1: mode switches only transition synchronizers) + */ + void setActiveMode(@NonNull ConnectionMode mode, boolean includeInitializers) { + this.activeMode = mode; + this.includeInitializers = includeInitializers; + } + + /** + * Returns the raw {@link ModeDefinition} for the given mode, used by + * {@link ConnectivityManager} for the CSFDV2 5.3.8 equivalence check. + */ + ModeDefinition getModeDefinition(@NonNull ConnectionMode mode) { + return modeTable.get(mode); + } + + @Override + public DataSource build(ClientContext clientContext) { + if (modeTable == null) { + modeTable = makeDefaultModeTable(); + } + + ConnectionMode mode = activeMode != null ? activeMode : startingMode; + + ModeDefinition modeDef = modeTable.get(mode); + if (modeDef == null) { + throw new IllegalStateException( + "Mode " + mode + " not found in mode table"); + } + + ResolvedModeDefinition resolved = resolve(modeDef, clientContext); + + DataSourceUpdateSink baseSink = clientContext.getDataSourceUpdateSink(); + if (!(baseSink instanceof DataSourceUpdateSinkV2)) { + throw new IllegalStateException( + "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); + } + + List> initFactories = + includeInitializers ? resolved.getInitializerFactories() : Collections.>emptyList(); + + // Reset includeInitializers to default after each build to prevent stale state. + includeInitializers = true; + + return new FDv2DataSource( + clientContext.getEvaluationContext(), + initFactories, + resolved.getSynchronizerFactories(), + (DataSourceUpdateSinkV2) baseSink, + ClientContextImpl.get(clientContext).getTaskExecutor(), + clientContext.getBaseLogger() + ); + } + + private static ResolvedModeDefinition resolve( + ModeDefinition def, ClientContext clientContext + ) { + List> initFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getInitializers()) { + initFactories.add(() -> configurer.build(clientContext)); + } + List> syncFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getSynchronizers()) { + syncFactories.add(() -> configurer.build(clientContext)); + } + return new ResolvedModeDefinition(initFactories, syncFactories); + } + + /** + * Holds the shared infrastructure needed by all FDv2 data source components: + * a {@link SelectorSource} backed by the {@link TransactionalDataStore} (or an empty + * fallback if none is configured), and the {@link HttpProperties} for the current + * client configuration. Polling-specific setup (the {@link FDv2Requestor}) is built + * separately via {@link #makePollingRequestor}. + */ + private static final class DataSourceSetup { + final SelectorSource selectorSource; + final HttpProperties httpProps; + + DataSourceSetup(ClientContext ctx) { + TransactionalDataStore store = ClientContextImpl.get(ctx).getTransactionalDataStore(); + this.selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + this.httpProps = LDUtil.makeHttpProperties(ctx); + } + } + + /** + * Builds a {@link DefaultFDv2Requestor} configured for polling endpoints. Used + * directly by polling components and as the fallback requestor for the streaming + * synchronizer (which needs it for internal polling fallback when the stream cannot + * be established). + */ + private static FDv2Requestor makePollingRequestor(ClientContext ctx, HttpProperties httpProps) { + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + return new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java index fd9541d2..1219cdb7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java @@ -9,9 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; /** * Fallback and recovery conditions for switching between FDv2 synchronizers. @@ -34,18 +32,18 @@ interface Condition { } /** - * Base for conditions that complete after a timeout. Holds the result future, executor, + * Base for conditions that complete after a timeout. Holds the result future, task executor, * timeout, and optional timer; subclasses define when the timer is started and what completes the future. */ static abstract class TimedCondition implements Condition { protected final LDAwaitFuture resultFuture = new LDAwaitFuture<>(); - protected final ScheduledExecutorService sharedExecutor; + protected final TaskExecutor taskExecutor; protected final long timeoutSeconds; /** Future for the timeout task, if any. Null when no timeout is active. */ protected ScheduledFuture timerFuture; - TimedCondition(@NonNull ScheduledExecutorService sharedExecutor, long timeoutSeconds) { - this.sharedExecutor = sharedExecutor; + TimedCondition(@NonNull TaskExecutor taskExecutor, long timeoutSeconds) { + this.taskExecutor = taskExecutor; this.timeoutSeconds = timeoutSeconds; } @@ -68,8 +66,8 @@ public void close() { */ static final class FallbackCondition extends TimedCondition { - FallbackCondition(@NonNull ScheduledExecutorService executor, long timeoutSeconds) { - super(executor, timeoutSeconds); + FallbackCondition(@NonNull TaskExecutor taskExecutor, long timeoutSeconds) { + super(taskExecutor, timeoutSeconds); } @Override @@ -84,10 +82,9 @@ public void inform(@NonNull FDv2SourceResult result) { && result.getStatus() != null && result.getStatus().getState() == SourceSignal.INTERRUPTED) { if (timerFuture == null) { - timerFuture = sharedExecutor.schedule( + timerFuture = taskExecutor.scheduleTask( () -> resultFuture.set(ConditionType.FALLBACK), - timeoutSeconds, - TimeUnit.SECONDS); + timeoutSeconds * 1000); } } } @@ -103,12 +100,11 @@ public ConditionType getType() { */ static final class RecoveryCondition extends TimedCondition { - RecoveryCondition(@NonNull ScheduledExecutorService executor, long timeoutSeconds) { - super(executor, timeoutSeconds); - this.timerFuture = executor.schedule( + RecoveryCondition(@NonNull TaskExecutor taskExecutor, long timeoutSeconds) { + super(taskExecutor, timeoutSeconds); + this.timerFuture = taskExecutor.scheduleTask( () -> resultFuture.set(ConditionType.RECOVERY), - timeoutSeconds, - TimeUnit.SECONDS); + timeoutSeconds * 1000); } @Override diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java index 550af863..5ae79811 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java @@ -6,7 +6,6 @@ import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Initializer; -import java.util.concurrent.Executor; import java.util.concurrent.Future; /** @@ -23,24 +22,23 @@ */ final class FDv2PollingInitializer extends FDv2PollingBase implements Initializer { private final SelectorSource selectorSource; - private final Executor executor; + private final TaskExecutor taskExecutor; private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); /** * @param requestor the FDv2 requestor used to perform the poll * @param selectorSource source of the current selector - * @param executor executor used to run the poll task on a background thread; should use - * background-priority threads + * @param taskExecutor task executor used to run the poll on a background thread * @param logger logger */ FDv2PollingInitializer( @NonNull FDv2Requestor requestor, @NonNull SelectorSource selectorSource, - @NonNull Executor executor, + @NonNull TaskExecutor taskExecutor, @NonNull LDLogger logger) { super(requestor, logger); this.selectorSource = selectorSource; - this.executor = executor; + this.taskExecutor = taskExecutor; } @Override @@ -48,7 +46,7 @@ final class FDv2PollingInitializer extends FDv2PollingBase implements Initialize public Future run() { LDAwaitFuture pollFuture = new LDAwaitFuture<>(); - executor.execute(() -> { + taskExecutor.startTask(() -> { try { FDv2SourceResult result = doPoll(selectorSource.getSelector(), true); pollFuture.set(result); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java index c35f20e0..cfebe1ab 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java @@ -9,9 +9,7 @@ import com.launchdarkly.sdk.fdv2.SourceSignal; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; /** * FDv2 polling synchronizer: polls at a fixed interval and delivers each result via @@ -37,8 +35,7 @@ final class FDv2PollingSynchronizer extends FDv2PollingBase implements Synchroni /** * @param requestor the FDv2 requestor used to perform each poll * @param selectorSource source of the current selector, sent as the {@code basis} param - * @param executor scheduler for recurring poll tasks; should use background-priority - * threads to match the behaviour of {@link FDv2PollingInitializer} + * @param taskExecutor task executor for scheduling recurring poll tasks * @param initialDelayMillis delay before the first poll in milliseconds; use {@code 0} to * poll immediately (e.g. foreground polling). A non-zero value is * used when transitioning from streaming to background polling so @@ -50,7 +47,7 @@ final class FDv2PollingSynchronizer extends FDv2PollingBase implements Synchroni FDv2PollingSynchronizer( @NonNull FDv2Requestor requestor, @NonNull SelectorSource selectorSource, - @NonNull ScheduledExecutorService executor, + @NonNull TaskExecutor taskExecutor, long initialDelayMillis, long pollIntervalMillis, @NonNull LDLogger logger) { @@ -58,11 +55,10 @@ final class FDv2PollingSynchronizer extends FDv2PollingBase implements Synchroni this.selectorSource = selectorSource; synchronized (taskLock) { - scheduledTask = executor.scheduleWithFixedDelay( + scheduledTask = taskExecutor.startTaskWithFixedDelay( this::pollAndEnqueue, initialDelayMillis, - pollIntervalMillis, - TimeUnit.MILLISECONDS); + pollIntervalMillis); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java index 71d20339..d63c4358 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java @@ -33,7 +33,6 @@ import java.io.IOException; import java.net.URI; import java.util.Map; -import java.util.concurrent.Executor; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -70,7 +69,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { @Nullable private final DiagnosticStore diagnosticStore; private final LDLogger logger; - private final Executor executor; + private final TaskExecutor taskExecutor; private final LDAsyncQueue resultQueue = new LDAsyncQueue<>(); private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); @@ -93,8 +92,8 @@ final class FDv2StreamingSynchronizer implements Synchronizer { * @param evaluationReasons true to request evaluation reasons in the stream * @param useReport true to use HTTP REPORT for the request body * @param httpProperties HTTP configuration for the stream request - * @param executor executor used to run the streaming loop on a background - * thread; should use background-priority threads + * @param executor task executor used to run the streaming loop on a + * background thread * @param logger logger * @param diagnosticStore optional store for stream diagnostics; may be null */ @@ -108,7 +107,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { boolean evaluationReasons, boolean useReport, @NonNull HttpProperties httpProperties, - @NonNull Executor executor, + @NonNull TaskExecutor executor, @NonNull LDLogger logger, @Nullable DiagnosticStore diagnosticStore ) { @@ -121,7 +120,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { this.evaluationReasons = evaluationReasons; this.useReport = useReport; this.httpProperties = httpProperties; - this.executor = executor; + this.taskExecutor = executor; this.logger = logger; this.diagnosticStore = diagnosticStore; } @@ -208,7 +207,7 @@ private void startStream() { eventSource = es; } - executor.execute(() -> { + taskExecutor.startTask(() -> { streamStarted = System.currentTimeMillis(); try { for (StreamEvent event : es.anyEvents()) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java new file mode 100644 index 00000000..81d69b8f --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -0,0 +1,48 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.Collections; +import java.util.List; + +/** + * Defines the initializers and synchronizers for a single {@link ConnectionMode}. + * Each instance is a pure data holder — it stores {@link ComponentConfigurer} factories + * but does not create any concrete initializer or synchronizer objects. + *

+ * At build time, {@code FDv2DataSourceBuilder} resolves each {@link ComponentConfigurer} + * into a {@link FDv2DataSource.DataSourceFactory} by partially applying the + * {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. + *

+ * Package-private — not part of the public SDK API. + * + * @see ConnectionMode + * @see ResolvedModeDefinition + */ +final class ModeDefinition { + + private final List> initializers; + private final List> synchronizers; + + ModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers + ) { + this.initializers = Collections.unmodifiableList(initializers); + this.synchronizers = Collections.unmodifiableList(synchronizers); + } + + @NonNull + List> getInitializers() { + return initializers; + } + + @NonNull + List> getSynchronizers() { + return synchronizers; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java new file mode 100644 index 00000000..80fd1982 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java @@ -0,0 +1,43 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +/** + * A single entry in a {@link ModeResolutionTable}. Pairs a {@link Condition} + * predicate with the {@link ConnectionMode} that should be activated when the + * condition matches the current {@link ModeState}. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeResolutionTable + * @see ModeState + */ +final class ModeResolutionEntry { + + /** + * Functional interface for evaluating a {@link ModeState} against a condition. + * Defined here (rather than using {@code java.util.function.Predicate}) because + * {@code Predicate} requires API 24+ and the SDK targets minSdk 21. + */ + interface Condition { + boolean test(@NonNull ModeState state); + } + + private final Condition conditions; + private final ConnectionMode mode; + + ModeResolutionEntry(@NonNull Condition conditions, @NonNull ConnectionMode mode) { + this.conditions = conditions; + this.mode = mode; + } + + @NonNull + Condition getConditions() { + return conditions; + } + + @NonNull + ConnectionMode getMode() { + return mode; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java new file mode 100644 index 00000000..64c7fb23 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java @@ -0,0 +1,65 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * An ordered list of {@link ModeResolutionEntry} values that maps a {@link ModeState} + * to a {@link ConnectionMode}. The first entry whose condition matches wins. + * If no entry matches, a default {@link ConnectionMode} is returned. + *

+ * The {@link #MOBILE} constant defines the Android default resolution table: + *

    + *
  1. No network → {@link ConnectionMode#OFFLINE}
  2. + *
  3. Background + background updating disabled → {@link ConnectionMode#OFFLINE}
  4. + *
  5. Background → {@link ConnectionMode#BACKGROUND}
  6. + *
  7. Default → {@link ConnectionMode#STREAMING}
  8. + *
+ *

+ * Package-private — not part of the public SDK API. + * + * @see ModeState + * @see ModeResolutionEntry + */ +final class ModeResolutionTable { + + static final ModeResolutionTable MOBILE = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry( + state -> !state.isNetworkAvailable(), + ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.isForeground() && state.isBackgroundUpdatingDisabled(), + ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.isForeground(), + ConnectionMode.BACKGROUND) + ), ConnectionMode.STREAMING); + + private final List entries; + private final ConnectionMode defaultMode; + + ModeResolutionTable(@NonNull List entries, @NonNull ConnectionMode defaultMode) { + this.entries = Collections.unmodifiableList(entries); + this.defaultMode = defaultMode; + } + + /** + * Evaluates the table against the given state and returns the first matching mode. + * If no entry matches, returns the default mode. + * + * @param state the current platform state + * @return the resolved {@link ConnectionMode} + */ + @NonNull + ConnectionMode resolve(@NonNull ModeState state) { + for (ModeResolutionEntry entry : entries) { + if (entry.getConditions().test(state)) { + return entry.getMode(); + } + } + return defaultMode; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java new file mode 100644 index 00000000..ca86d907 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -0,0 +1,37 @@ +package com.launchdarkly.sdk.android; + +/** + * Snapshot of the current platform state used as input to + * {@link ModeResolutionTable#resolve(ModeState)}. + *

+ * Immutable value object — all fields are set in the constructor with no setters. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeResolutionTable + * @see ModeResolutionEntry + */ +final class ModeState { + + private final boolean foreground; + private final boolean networkAvailable; + private final boolean backgroundUpdatingDisabled; + + ModeState(boolean foreground, boolean networkAvailable, boolean backgroundUpdatingDisabled) { + this.foreground = foreground; + this.networkAvailable = networkAvailable; + this.backgroundUpdatingDisabled = backgroundUpdatingDisabled; + } + + boolean isForeground() { + return foreground; + } + + boolean isNetworkAvailable() { + return networkAvailable; + } + + boolean isBackgroundUpdatingDisabled() { + return backgroundUpdatingDisabled; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java new file mode 100644 index 00000000..e404a5ac --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -0,0 +1,45 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.Collections; +import java.util.List; + +/** + * A fully resolved mode definition containing zero-arg factories for initializers + * and synchronizers. This is the result of resolving a {@link ModeDefinition}'s + * {@link com.launchdarkly.sdk.android.subsystems.ComponentConfigurer} entries against + * a {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. + *

+ * Instances are immutable and created by {@code FDv2DataSourceBuilder} at build time. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + */ +final class ResolvedModeDefinition { + + private final List> initializerFactories; + private final List> synchronizerFactories; + + ResolvedModeDefinition( + @NonNull List> initializerFactories, + @NonNull List> synchronizerFactories + ) { + this.initializerFactories = Collections.unmodifiableList(initializerFactories); + this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); + } + + @NonNull + List> getInitializerFactories() { + return initializerFactories; + } + + @NonNull + List> getSynchronizerFactories() { + return synchronizerFactories; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java index c9c395e3..06c51e32 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java @@ -17,6 +17,10 @@ private StandardEndpoints() {} static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; + static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval"; + static final String FDV2_POLLING_REQUEST_REPORT_BASE_PATH = "/sdk/poll/eval"; + static final String FDV2_STREAMING_REQUEST_BASE_PATH = "/sdk/stream/eval"; + /** * Internal method to decide which URI a given component should connect to. *

diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java index eeb82181..7344472f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java @@ -37,4 +37,24 @@ interface TaskExecutor extends Closeable { * @return a ScheduledFuture that can be used to cancel the task */ ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis); + + /** + * Submits a long-running action to the parallel (dynamically-pooled) executor. Use this for + * blocking or compute-intensive work that must not occupy the single-thread scheduled pool. + * + * @param action the action to execute + */ + void startTask(Runnable action); + + /** + * Schedules an action to run repeatedly with a fixed delay between the end of one + * execution and the start of the next, using the parallel executor. The returned + * future can be cancelled to stop the recurring task. + * + * @param action the action to execute at each interval + * @param initialDelayMillis milliseconds to wait before the first execution + * @param delayMillis milliseconds between the end of one execution and the start of the next + * @return a ScheduledFuture that can be used to cancel the task + */ + ScheduledFuture startTaskWithFixedDelay(Runnable action, long initialDelayMillis, long delayMillis); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 26523e5e..86f3a148 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -37,8 +37,14 @@ import org.junit.Test; import org.junit.rules.Timeout; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -565,6 +571,138 @@ public void refreshDataSourceWhileInBackgroundWithBackgroundPollingDisabled() { verifyNoMoreDataSourcesWereCreated(); } + // ==== FDv1 state-transition round-trip tests ==== + // + // These tests exercise the FDv1 code path through state transitions that were added or + // restructured alongside the FDv2 work, ensuring the FDv1 flow is unaffected. + + @Test + public void fdv1_shutDown_doesNotCallCloseOnFactory() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + connectivityManager.shutDown(); + verifyDataSourceWasStopped(); + } + + @Test + public void fdv1_setOffline_thenBackOnline_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + resetAll(); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + connectivityManager.setForceOffline(true); + ConnectionMode offlineMode = awaitConnectionModeChangedFrom(ConnectionMode.POLLING); + assertEquals(ConnectionMode.SET_OFFLINE, offlineMode); + verifyDataSourceWasStopped(); + verifyAll(); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + connectivityManager.setForceOffline(false); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + } + + @Test + public void fdv1_networkLost_thenRestored_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + resetAll(); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + ConnectionMode offlineMode = awaitConnectionModeChangedFrom(ConnectionMode.POLLING); + assertEquals(ConnectionMode.OFFLINE, offlineMode); + verifyDataSourceWasStopped(); + verifyAll(); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + } + + @Test + public void fdv1_foregroundToBackground_thenBackToForeground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + ConnectionMode bgMode = awaitConnectionModeChangedFrom(ConnectionMode.POLLING); + assertEquals(ConnectionMode.BACKGROUND_POLLING, bgMode); + verifyDataSourceWasStopped(); + verifyBackgroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + verifyDataSourceWasStopped(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + } + + @Test + public void fdv1_forDataSource_transactionalDataStoreIsPassedThrough() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, clientContext -> { + receivedClientContexts.add(clientContext); + ClientContextImpl impl = ClientContextImpl.get(clientContext); + assertNotNull(impl.getTransactionalDataStore()); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.POLLING, startedDataSources, stoppedDataSources); + }); + + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + } + @Test public void notifyListenersWhenStatusChanges() throws Exception { createTestManager(false, false, makeSuccessfulDataSourceFactory()); @@ -657,4 +795,247 @@ private void verifyNoMoreDataSourcesWereCreated() { private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } + + // ==== FDv2 mode resolution tests ==== + + /** + * Creates a test FDv2DataSourceBuilder that returns mock data sources + * which track start/stop via the shared queues. Each build() call creates + * a new mock data source. + */ + private FDv2DataSourceBuilder makeFDv2DataSourceFactory() { + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + } + + @Test + public void fdv2_foregroundToBackground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "new data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "new data source started"); + verifyAll(); + } + + @Test + public void fdv2_backgroundToForeground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "fg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "fg data source started"); + + verifyAll(); + } + + @Test + public void fdv2_networkLost_rebuildsToOffline() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + + verifyDataSourceWasStopped(); + // OFFLINE mode should still build a new data source (with no synchronizers) + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); + verifyAll(); + } + + @Test + public void fdv2_forceOffline_rebuildsToOffline() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + connectivityManager.setForceOffline(true); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); + verifyAll(); + } + + @Test + public void fdv2_sameModeDoesNotRebuild() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + verifyAll(); + } + + @Test + public void fdv2_equivalentConfigDoesNotRebuild() throws Exception { + ModeDefinition sharedDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, sharedDef); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, sharedDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + createTestManager(false, false, builder); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + // STREAMING and BACKGROUND share the same ModeDefinition object, so 5.3.8 says no rebuild + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + verifyAll(); + } + + @Test + public void fdv2_contextChange_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + LDContext context2 = LDContext.create("context2"); + contextDataManager.switchToContext(context2); + AwaitableCallback done = new AwaitableCallback<>(); + connectivityManager.switchToContext(context2, done); + done.await(); + + verifyDataSourceWasStopped(); + verifyForegroundDataSourceWasCreatedAndStarted(context2); + verifyNoMoreDataSourcesWereCreated(); + verifyAll(); + } + + @Test + public void fdv2_modeSwitchDoesNotIncludeInitializers() throws Exception { + BlockingQueue initializerIncluded = new LinkedBlockingQueue<>(); + + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + // After setActiveMode(mode, includeInitializers), build() resets includeInitializers + // to true. We can observe this by checking what build() would produce. The super.build() + // uses the includeInitializers flag internally. + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + createTestManager(false, false, builder); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); + verifyAll(); + } } \ No newline at end of file diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java new file mode 100644 index 00000000..bb4dd6d8 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -0,0 +1,193 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; +import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; +import com.launchdarkly.sdk.android.env.IEnvironmentReporter; +import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import org.junit.Rule; +import org.junit.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class FDv2DataSourceBuilderTest { + + private static final LDContext CONTEXT = LDContext.create("test-context"); + private static final IEnvironmentReporter ENV_REPORTER = new EnvironmentReporterBuilder().build(); + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + private ClientContext makeClientContext() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + ClientContext base = new ClientContext( + "mobile-key", + ENV_REPORTER, + logging.logger, + config, + sink, + "default", + false, + CONTEXT, + null, + false, + null, + config.serviceEndpoints, + false + ); + return new ClientContextImpl(base, null, null, null, new SimpleTestTaskExecutor(), null); + } + + @Test + public void defaultBuilder_buildsFDv2DataSource() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + assertTrue(ds instanceof FDv2DataSource); + } + + @Test + public void customModeTable_buildsCorrectly() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void startingMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } + } + + @Test + public void setActiveMode_buildUsesSpecifiedMode() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.setActiveMode(ConnectionMode.POLLING, true); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void setActiveMode_withoutInitializers_buildsWithEmptyInitializers() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.setActiveMode(ConnectionMode.STREAMING, false); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void defaultBehavior_usesStartingMode() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void getModeDefinition_returnsCorrectDefinition() { + ModeDefinition streamingDef = new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + ); + ModeDefinition pollingDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, streamingDef); + customTable.put(ConnectionMode.POLLING, pollingDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + assertEquals(streamingDef, builder.getModeDefinition(ConnectionMode.STREAMING)); + assertEquals(pollingDef, builder.getModeDefinition(ConnectionMode.POLLING)); + assertNull(builder.getModeDefinition(ConnectionMode.OFFLINE)); + } + + @Test + public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { + ModeDefinition sharedDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, sharedDef); + customTable.put(ConnectionMode.POLLING, sharedDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + // Identity check: same ModeDefinition object shared across modes enables 5.3.8 equivalence + assertTrue(builder.getModeDefinition(ConnectionMode.STREAMING) + == builder.getModeDefinition(ConnectionMode.POLLING)); + } + + @Test + public void setActiveMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.setActiveMode(ConnectionMode.POLLING, true); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java index 77126e47..0342988c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java @@ -18,9 +18,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -36,11 +34,11 @@ public class FDv2DataSourceConditionsTest { @Rule public Timeout globalTimeout = Timeout.seconds(5); - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } // ---- helpers ---- diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index ad1e91e3..dc7ff7bc 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -39,9 +39,7 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -59,17 +57,17 @@ public class FDv2DataSourceTest { private static final long AWAIT_TIMEOUT_SECONDS = 10; - private ScheduledExecutorService executor; + private TaskExecutor executor; @Before public void setUp() { - executor = Executors.newScheduledThreadPool(2); + executor = new SimpleTestTaskExecutor(); } @After - public void tearDown() { - if (executor != null && !executor.isShutdown()) { - executor.shutdownNow(); + public void tearDown() throws Exception { + if (executor != null) { + executor.close(); } } @@ -1420,4 +1418,23 @@ public void stopReportsOffStatus() throws Exception { DataSourceState offStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertEquals(DataSourceState.OFF, offStatus); } + + @Test + public void needsRefresh_sameContext_returnsFalse() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, + Collections.emptyList(), + Collections.emptyList()); + assertFalse(dataSource.needsRefresh(true, CONTEXT)); + assertFalse(dataSource.needsRefresh(false, CONTEXT)); + } + + @Test + public void needsRefresh_differentContext_returnsTrue() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, + Collections.emptyList(), + Collections.emptyList()); + assertTrue(dataSource.needsRefresh(false, LDContext.create("other-context"))); + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java index 3f258a02..5c4ff9e0 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java @@ -16,8 +16,6 @@ import java.io.IOException; import java.util.List; 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; @@ -32,11 +30,11 @@ public class FDv2PollingInitializerTest { @Rule public Timeout globalTimeout = Timeout.seconds(5); - private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } // ---- helpers ---- diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java index ed982f11..1831b7db 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java @@ -15,9 +15,7 @@ import java.io.IOException; import java.util.List; -import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; @@ -31,11 +29,11 @@ public class FDv2PollingSynchronizerTest { @Rule public Timeout globalTimeout = Timeout.seconds(5); - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } // ---- helpers ---- diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java index ab123fbd..a8700e2a 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java @@ -21,8 +21,6 @@ import java.io.IOException; import java.net.URI; import java.util.HashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -38,11 +36,11 @@ public class FDv2StreamingSynchronizerTest { @Rule public Timeout globalTimeout = Timeout.seconds(10); - private final ExecutorService executor = Executors.newCachedThreadPool(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } private static final LDContext CONTEXT = LDContext.create("test-context"); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java new file mode 100644 index 00000000..f70990f0 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -0,0 +1,96 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; + +public class ModeResolutionTableTest { + + // ==== MOBILE table tests ==== + + @Test + public void mobile_foregroundWithNetwork_resolvesToStreaming() { + ModeState state = new ModeState(true, true, false); + assertSame(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithNetwork_resolvesToBackground() { + ModeState state = new ModeState(false, true, false); + assertSame(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithNetworkAndBackgroundDisabled_resolvesToOffline() { + ModeState state = new ModeState(false, true, true); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_foregroundWithoutNetwork_resolvesToOffline() { + ModeState state = new ModeState(true, false, false); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithoutNetwork_resolvesToOffline() { + ModeState state = new ModeState(false, false, false); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_foregroundWithBackgroundDisabled_resolvesToStreaming() { + ModeState state = new ModeState(true, true, true); + assertSame(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + } + + // ==== Custom table tests ==== + + @Test + public void customTable_firstMatchWins() { + ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), + new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) + ), ConnectionMode.OFFLINE); + assertSame(ConnectionMode.POLLING, table.resolve(new ModeState(true, true, false))); + } + + @Test + public void emptyTable_returnsDefault() { + ModeResolutionTable table = new ModeResolutionTable( + Collections.emptyList(), ConnectionMode.STREAMING); + assertSame(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true, false))); + } + + @Test + public void noMatch_returnsDefault() { + ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( + new ModeResolutionEntry(state -> false, ConnectionMode.POLLING) + ), ConnectionMode.STREAMING); + assertSame(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true, false))); + } + + // ==== ModeState tests ==== + + @Test + public void modeState_getters() { + ModeState state = new ModeState(true, false, true); + assertEquals(true, state.isForeground()); + assertEquals(false, state.isNetworkAvailable()); + assertEquals(true, state.isBackgroundUpdatingDisabled()); + } + + // ==== ModeResolutionEntry tests ==== + + @Test + public void modeResolutionEntry_getters() { + ModeResolutionEntry.Condition cond = state -> true; + ModeResolutionEntry entry = new ModeResolutionEntry(cond, ConnectionMode.OFFLINE); + assertSame(cond, entry.getConditions()); + assertSame(ConnectionMode.OFFLINE, entry.getMode()); + } +} diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java index 4d99a282..7c0d12d3 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java @@ -63,6 +63,15 @@ public void removeForegroundChangeListener(ForegroundChangeListener listener) { foregroundChangeListeners.remove(listener); } + public void setAndNotifyConnectivityChangeListeners(boolean networkAvailable) { + this.networkAvailable = networkAvailable; + new Thread(() -> { + for (ConnectivityChangeListener listener: connectivityChangeListeners) { + listener.onConnectivityChanged(networkAvailable); + } + }).start(); + } + public void setAndNotifyForegroundChangeListeners(boolean foreground) { this.foreground = foreground; new Thread(() -> { diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java index f53c0dcb..27c75a8e 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.android; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -14,7 +15,8 @@ public class SimpleTestTaskExecutor implements TaskExecutor { private static final ThreadLocal fakeMainThread = new ThreadLocal<>(); - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + private final ExecutorService parallelExecutor = Executors.newCachedThreadPool(); @Override public void executeOnMainThread(Runnable action) { @@ -26,18 +28,30 @@ public void executeOnMainThread(Runnable action) { @Override public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { - return executor.schedule(action, delayMillis, TimeUnit.MILLISECONDS); + return scheduledExecutor.schedule(action, delayMillis, TimeUnit.MILLISECONDS); } @Override public ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis) { - return executor.scheduleAtFixedRate(action, + return scheduledExecutor.scheduleAtFixedRate(action, initialDelayMillis, intervalMillis, TimeUnit.MILLISECONDS); } + @Override + public void startTask(Runnable action) { + parallelExecutor.execute(action); + } + + @Override + public ScheduledFuture startTaskWithFixedDelay(Runnable action, long initialDelayMillis, long delayMillis) { + return scheduledExecutor.scheduleWithFixedDelay(action, + initialDelayMillis, delayMillis, TimeUnit.MILLISECONDS); + } + @Override public void close() { - executor.shutdownNow(); + scheduledExecutor.shutdownNow(); + parallelExecutor.shutdownNow(); } public boolean isThisTheFakeMainThread() {