diff --git a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java index bb37319b..dfa33f79 100644 --- a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java +++ b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java @@ -396,6 +396,50 @@ public void testConfigurationChangeListener() throws ExecutionException, Interru assertEquals(3, received.size()); } + @Test + public void testConfigurationChangeListenerOneShot() + throws ExecutionException, InterruptedException { + List received = new ArrayList<>(); + + // Set up a changing response from the "server" + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + // Mock sync get to return empty + when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG); + + // Mock async get to return empty + CompletableFuture emptyResponse = CompletableFuture.completedFuture(EMPTY_CONFIG); + when(mockHttpClient.getAsync(anyString())).thenReturn(emptyResponse); + + setBaseClientHttpClientOverrideField(mockHttpClient); + + EppoClient.Builder clientBuilder = + new EppoClient.Builder(DUMMY_API_KEY, ApplicationProvider.getApplicationContext()) + .forceReinitialize(true) + .onConfigurationChange(received::add, true) // oneShot = true + .isGracefulMode(false); + + // Initialize and no exception should be thrown. + EppoClient eppoClient = clientBuilder.buildAndInitAsync().get(); + + verify(mockHttpClient, times(1)).getAsync(anyString()); + assertEquals(1, received.size()); + + // Now, return the boolean flag config so that the config has changed. + when(mockHttpClient.get(anyString())).thenReturn(BOOL_FLAG_CONFIG); + + // Trigger a reload of the client + eppoClient.loadConfiguration(); + + // Callback should NOT be invoked again because it was oneShot + assertEquals(1, received.size()); + + // Reload the client again to verify it's still not called + eppoClient.loadConfiguration(); + + assertEquals(1, received.size()); + } + @Test public void testPollingClient() throws ExecutionException, InterruptedException { EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 2090c2d6..772fcff0 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -157,6 +157,31 @@ public static class Builder { private IAssignmentCache assignmentCache = new LRUAssignmentCache(100); @Nullable private Consumer configChangeCallback; + /** + * Wrapper for one-shot configuration change callbacks that auto-unsubscribes after first + * invocation. + */ + private static class OneShotConfigCallback implements Consumer { + private final Consumer delegate; + private Runnable unsubscriber; + + OneShotConfigCallback(Consumer delegate) { + this.delegate = delegate; + } + + void setUnsubscriber(Runnable unsubscriber) { + this.unsubscriber = unsubscriber; + } + + @Override + public void accept(Configuration config) { + if (unsubscriber != null) { + unsubscriber.run(); + } + delegate.accept(config); + } + } + public Builder(@NonNull String apiKey, @NonNull Application application) { this.application = application; this.apiKey = apiKey; @@ -261,6 +286,20 @@ public Builder onConfigurationChange(Consumer configChangeCallbac return this; } + /** + * Registers a callback for when a new configuration is applied to the `EppoClient` instance. + * + * @param configChangeCallback The callback to invoke when configuration changes + * @param oneShot If true, the callback will be automatically unsubscribed after the first + * invocation + */ + public Builder onConfigurationChange( + Consumer configChangeCallback, boolean oneShot) { + this.configChangeCallback = + oneShot ? new OneShotConfigCallback(configChangeCallback) : configChangeCallback; + return this; + } + public CompletableFuture buildAndInitAsync() { if (application == null) { throw new MissingApplicationException(); @@ -311,7 +350,11 @@ public CompletableFuture buildAndInitAsync() { assignmentCache); if (configChangeCallback != null) { - instance.onConfigurationChange(configChangeCallback); + Runnable unsubscriber = instance.onConfigurationChange(configChangeCallback); + // If this is a one-shot callback, set the unsubscriber so it can clean up after first call + if (configChangeCallback instanceof OneShotConfigCallback) { + ((OneShotConfigCallback) configChangeCallback).setUnsubscriber(unsubscriber); + } } final CompletableFuture ret = new CompletableFuture<>();