diff --git a/.gitignore b/.gitignore index fe9c8519..4745641c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ docs .idea build/ .gradle/ +CLAUDE.md diff --git a/README.md b/README.md index 2461b49a..84b6e22d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ No worries, here are some links that you will find useful: * **[Android integration video tutorial](https://www.youtube.com/watch?v=KcpOa93eSVs)** * **[Full API Reference](http://mixpanel.github.io/mixpanel-android/index.html)** +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mixpanel/mixpanel-android) + Have any questions? Reach out to [support@mixpanel.com](mailto:support@mixpanel.com) to speak to someone smart, quickly. diff --git a/build.gradle b/build.gradle index c56d9b70..afc45408 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.4' + classpath 'com.android.tools.build:gradle:8.10.0' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0' } } @@ -56,7 +56,6 @@ android { buildTypes { debug{ minifyEnabled false - debuggable true testCoverageEnabled true } release { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3e976a64..9c862979 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java index c128b266..85ac728f 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java @@ -6,6 +6,8 @@ import android.os.HandlerThread; import android.os.Process; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; import androidx.test.platform.app.InstrumentationRegistry; @@ -63,7 +65,14 @@ public void setUp() { private void setUpInstance(boolean trackAutomaticEvents) { final RemoteService mockPoster = new HttpService() { @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) { + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + { final String jsonData = Base64Coder.decodeString(params.get("data").toString()); assertTrue(params.containsKey("data")); @@ -212,7 +221,14 @@ public void testAutomaticMultipleInstances() throws InterruptedException { final HttpService mpSecondPoster = new HttpService() { @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) { + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + { final String jsonData = Base64Coder.decodeString(params.get("data").toString()); assertTrue(params.containsKey("data")); try { diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java new file mode 100644 index 00000000..a0603cef --- /dev/null +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java @@ -0,0 +1,1198 @@ +package com.mixpanel.android.mpmetrics; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.mixpanel.android.util.MPLog; +import com.mixpanel.android.util.OfflineMode; // Assuming this exists +import com.mixpanel.android.util.ProxyServerInteractor; +import com.mixpanel.android.util.RemoteService; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.lang.reflect.Field; + +import javax.net.ssl.SSLSocketFactory; + +import static org.junit.Assert.*; + +@RunWith(AndroidJUnit4.class) +public class FeatureFlagManagerTest { + + private FeatureFlagManager mFeatureFlagManager; + private MockFeatureFlagDelegate mMockDelegate; + private MockRemoteService mMockRemoteService; + private MPConfig mTestConfig; + private Context mContext; + + private static final String TEST_SERVER_URL = "https://test.mixpanel.com"; + private static final String TEST_DISTINCT_ID = "test_distinct_id"; + private static final String TEST_TOKEN = "test_token"; + private static final long ASYNC_TEST_TIMEOUT_MS = 2000; // 2 seconds + + // Helper class for capturing requests made to the mock service + private static class CapturedRequest { + final String endpointUrl; + final Map headers; + final byte[] requestBodyBytes; + + CapturedRequest(String endpointUrl, Map headers, byte[] requestBodyBytes) { + this.endpointUrl = endpointUrl; + this.headers = headers; + this.requestBodyBytes = requestBodyBytes; + } + + public JSONObject getRequestBodyAsJson() throws JSONException { + if (requestBodyBytes == null) return null; + return new JSONObject(new String(requestBodyBytes, StandardCharsets.UTF_8)); + } + } + + private static class MockFeatureFlagDelegate implements FeatureFlagDelegate { + MPConfig configToReturn; + String distinctIdToReturn = TEST_DISTINCT_ID; + String tokenToReturn = TEST_TOKEN; + List trackCalls = new ArrayList<>(); + CountDownLatch trackCalledLatch; // Optional: for tests waiting for track + + static class TrackCall { + final String eventName; + final JSONObject properties; + TrackCall(String eventName, JSONObject properties) { + this.eventName = eventName; + this.properties = properties; + } + } + + public MockFeatureFlagDelegate(MPConfig config) { + this.configToReturn = config; + } + + @Override + public MPConfig getMPConfig() { + return configToReturn; + } + + @Override + public String getDistinctId() { + return distinctIdToReturn; + } + + @Override + public void track(String eventName, JSONObject properties) { + MPLog.v("FeatureFlagManagerTest", "MockDelegate.track called: " + eventName); + trackCalls.add(new TrackCall(eventName, properties)); + if (trackCalledLatch != null) { + trackCalledLatch.countDown(); + } + } + + @Override + public String getToken() { + return tokenToReturn; + } + + public void resetTrackCalls() { + trackCalls.clear(); + trackCalledLatch = null; + } + } + + private static class MockRemoteService implements RemoteService { + // Queue to hold responses/exceptions to be returned by performRequest + private final BlockingQueue mResults = new ArrayBlockingQueue<>(10); + // Queue to capture actual requests made + private final BlockingQueue mCapturedRequests = new ArrayBlockingQueue<>(10); + + @Override + public boolean isOnline(Context context, OfflineMode offlineMode) { + return true; // Assume online for tests unless specified + } + + @Override + public void checkIsMixpanelBlocked() { + // No-op for tests + } + + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, + @Nullable SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + + mCapturedRequests.offer(new CapturedRequest(endpointUrl, headers, requestBodyBytes)); + + try { + Object result = mResults.poll(FeatureFlagManagerTest.ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (result == null) { + throw new IOException("MockRemoteService timed out waiting for a result to be queued."); + } + if (result instanceof IOException) { + throw (IOException) result; + } + if (result instanceof ServiceUnavailableException) { + throw (ServiceUnavailableException) result; + } + if (result instanceof RuntimeException) { // For other test exceptions + throw (RuntimeException) result; + } + return (byte[]) result; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("MockRemoteService interrupted.", e); + } + } + + public void addResponse(byte[] responseBytes) { + mResults.offer(responseBytes); + } + + public void addResponse(JSONObject responseJson) { + mResults.offer(responseJson.toString().getBytes(StandardCharsets.UTF_8)); + } + + public void addError(Exception e) { + mResults.offer(e); + } + + public CapturedRequest takeRequest(long timeout, TimeUnit unit) throws InterruptedException { + return mCapturedRequests.poll(timeout, unit); + } + + public void reset() { + mResults.clear(); + mCapturedRequests.clear(); + } + } + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + // MPConfig requires a context and a token (even if we override methods) + // Create a basic MPConfig. Specific flag settings will be set via MockDelegate. + mTestConfig = new MPConfig(new Bundle(), mContext, TEST_TOKEN); + + + mMockDelegate = new MockFeatureFlagDelegate(mTestConfig); + mMockRemoteService = new MockRemoteService(); + + mFeatureFlagManager = new FeatureFlagManager( + mMockDelegate, // Pass delegate directly, manager will wrap in WeakReference + mMockRemoteService, + new FlagsConfig(true, new JSONObject()) + ); + MPLog.setLevel(MPLog.VERBOSE); // Enable verbose logging for tests + } + + @After + public void tearDown() { + // Ensure handler thread is quit if it's still running, though manager re-creation handles it + // For more robust cleanup, FeatureFlagManager could have a .release() method + } + + // Helper method to create a valid flags JSON response string + private String createFlagsResponseJson(Map flags) { + JSONObject flagsObject = new JSONObject(); + try { + for (Map.Entry entry : flags.entrySet()) { + JSONObject flagDef = new JSONObject(); + flagDef.put("variant_key", entry.getValue().key); + // Need to handle different types for value properly + if (entry.getValue().value == null) { + flagDef.put("variant_value", JSONObject.NULL); + } else { + flagDef.put("variant_value", entry.getValue().value); + } + flagsObject.put(entry.getKey(), flagDef); + } + return new JSONObject().put("flags", flagsObject).toString(); + } catch (JSONException e) { + throw new RuntimeException("Error creating test JSON", e); + } + } + + // Helper to simulate MPConfig having specific FlagsConfig + private void setupFlagsConfig(boolean enabled, @Nullable JSONObject context) { + final JSONObject finalContext = (context == null) ? new JSONObject() : context; + final FlagsConfig flagsConfig = new FlagsConfig(enabled, finalContext); + + mMockDelegate.configToReturn = new MPConfig(new Bundle(), mContext, TEST_TOKEN) { + @Override + public String getEventsEndpoint() { // Ensure server URL source + return TEST_SERVER_URL + "/track/"; + } + + @Override + public String getFlagsEndpoint() { // Ensure server URL source + return TEST_SERVER_URL + "/flags/"; + } + }; + + mFeatureFlagManager = new FeatureFlagManager( + mMockDelegate, + mMockRemoteService, + flagsConfig + ); + } + + // ---- Test Cases ---- + + @Test + public void testAreFlagsReady_initialState() { + assertFalse("Features should not be ready initially", mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testLoadFlags_whenDisabled_doesNotFetch() throws InterruptedException { + setupFlagsConfig(false, null); // Flags disabled + mFeatureFlagManager.loadFlags(); + + // Wait a bit to ensure no network call is attempted + Thread.sleep(200); // Give handler thread time to process if it were to fetch + + CapturedRequest request = mMockRemoteService.takeRequest(100, TimeUnit.MILLISECONDS); + assertNull("No network request should be made when flags are disabled", request); + assertFalse("areFeaturesReady should be false", mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testLoadFlags_whenEnabled_andFetchSucceeds_flagsBecomeReady() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); // Flags enabled + + Map testFlags = new HashMap<>(); + testFlags.put("flag1", new MixpanelFlagVariant("v1", true)); + String responseJson = createFlagsResponseJson(testFlags); + mMockRemoteService.addResponse(responseJson.getBytes(StandardCharsets.UTF_8)); + + mFeatureFlagManager.loadFlags(); + + // Wait for fetch to complete (network call + handler processing) + // Ideally, use CountDownLatch if loadFlags had a completion, + // but for now, poll areFeaturesReady or wait a fixed time. + boolean ready = false; + for (int i = 0; i < 20; i++) { // Poll for up to 2 seconds + if (mFeatureFlagManager.areFlagsReady()) { + ready = true; + break; + } + Thread.sleep(100); + } + assertTrue("Flags should become ready after successful fetch", ready); + + CapturedRequest request = mMockRemoteService.takeRequest(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); + assertNotNull("A network request should have been made", request); + assertTrue("Endpoint should be for flags", request.endpointUrl.endsWith("/flags/")); + } + + + @Test + public void testLoadFlags_whenEnabled_andFetchFails_flagsNotReady() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + mMockRemoteService.addError(new IOException("Network unavailable")); + + mFeatureFlagManager.loadFlags(); + + // Wait a bit to see if flags become ready (they shouldn't) + Thread.sleep(500); // Enough time for the fetch attempt and failure processing + + assertFalse("Flags should not be ready after failed fetch", mFeatureFlagManager.areFlagsReady()); + CapturedRequest request = mMockRemoteService.takeRequest(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); + assertNotNull("A network request should have been attempted", request); + } + + @Test + public void testGetVariantSync_flagsNotReady_returnsFallback() { + setupFlagsConfig(true, null); // Enabled, but no flags loaded yet + assertFalse(mFeatureFlagManager.areFlagsReady()); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_key", "fb_value"); + MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("my_flag", fallback); + + assertEquals("Should return fallback key", fallback.key, result.key); + assertEquals("Should return fallback value", fallback.value, result.value); + } + + @Test + public void testGetVariantSync_flagsReady_flagExists() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("test_flag", new MixpanelFlagVariant("variant_A", "hello")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + // Wait for flags to load + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fallback_key", "fallback_value"); + MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("test_flag", fallback); + + assertEquals("Should return actual flag key", "variant_A", result.key); + assertEquals("Should return actual flag value", "hello", result.value); + } + + @Test + public void testGetVariantSync_flagsReady_flagMissing_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("another_flag", new MixpanelFlagVariant("variant_B", 123)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_key_sync", "fb_value_sync"); + MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("non_existent_flag", fallback); + + assertEquals("Should return fallback key", fallback.key, result.key); + assertEquals("Should return fallback value", fallback.value, result.value); + } + + @Test + public void testGetVariant_Async_flagsReady_flagExists() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("async_flag", new MixpanelFlagVariant("v_async", true)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb", false); + + mFeatureFlagManager.getVariant("async_flag", fallback, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertEquals("v_async", resultRef.get().key); + assertEquals(true, resultRef.get().value); + } + + @Test + public void testGetVariant_Async_flagsNotReady_fetchSucceeds() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); // Enabled for fetch + assertFalse(mFeatureFlagManager.areFlagsReady()); + + Map serverFlags = new HashMap<>(); + serverFlags.put("fetch_flag_async", new MixpanelFlagVariant("fetched_variant", "fetched_value")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + // No loadFlags() call here, getFeature should trigger it + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_fetch", "fb_val_fetch"); + + mFeatureFlagManager.getVariant("fetch_flag_async", fallback, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); // Longer timeout for fetch + assertNotNull(resultRef.get()); + assertEquals("fetched_variant", resultRef.get().key); + assertEquals("fetched_value", resultRef.get().value); + assertTrue(mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testTracking_getVariantSync_calledOnce() throws InterruptedException, JSONException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("track_flag_sync", new MixpanelFlagVariant("v_track_sync", "val")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + mMockDelegate.resetTrackCalls(); + mMockDelegate.trackCalledLatch = new CountDownLatch(1); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("", null); + mFeatureFlagManager.getVariantSync("track_flag_sync", fallback); // First call, should track + assertTrue("Track should have been called", mMockDelegate.trackCalledLatch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals("Track should be called once", 1, mMockDelegate.trackCalls.size()); + + // Second call, should NOT track again + mFeatureFlagManager.getVariantSync("track_flag_sync", fallback); + // Allow some time for potential erroneous track call + Thread.sleep(200); + assertEquals("Track should still be called only once", 1, mMockDelegate.trackCalls.size()); + + MockFeatureFlagDelegate.TrackCall call = mMockDelegate.trackCalls.get(0); + assertEquals("$experiment_started", call.eventName); + assertEquals("track_flag_sync", call.properties.getString("Experiment name")); + assertEquals("v_track_sync", call.properties.getString("Variant name")); + } + + @Test + public void testGetVariant_Async_flagsNotReady_fetchFails_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + mMockRemoteService.addError(new IOException("Simulated fetch failure")); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + final MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_async_fail", "val_async_fail"); + + mFeatureFlagManager.getVariant("some_flag_on_fail", fallback, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertEquals(fallback.key, resultRef.get().key); + assertEquals(fallback.value, resultRef.get().value); + assertFalse(mFeatureFlagManager.areFlagsReady()); + assertEquals(0, mMockDelegate.trackCalls.size()); // No tracking on fallback + } + + @Test + public void testIsEnabledSync_flagsReady_flagExistsWithBooleanTrue() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("bool_flag_true", new MixpanelFlagVariant("enabled", true)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("bool_flag_true", false); + assertTrue("Should return true when flag value is true", result); + } + + @Test + public void testIsEnabledSync_flagsReady_flagExistsWithBooleanFalse() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("bool_flag_false", new MixpanelFlagVariant("disabled", false)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("bool_flag_false", true); + assertFalse("Should return false when flag value is false", result); + } + + @Test + public void testIsEnabledSync_flagsReady_flagDoesNotExist_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("other_flag", new MixpanelFlagVariant("v1", "value")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("missing_bool_flag", true); + assertTrue("Should return fallback value (true) when flag doesn't exist", result); + + boolean result2 = mFeatureFlagManager.isEnabledSync("missing_bool_flag", false); + assertFalse("Should return fallback value (false) when flag doesn't exist", result2); + } + + @Test + public void testIsEnabledSync_flagsNotReady_returnsFallback() { + setupFlagsConfig(true, null); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("any_flag", true); + assertTrue("Should return fallback value (true) when flags not ready", result); + + boolean result2 = mFeatureFlagManager.isEnabledSync("any_flag", false); + assertFalse("Should return fallback value (false) when flags not ready", result2); + } + + @Test + public void testIsEnabledSync_flagsReady_nonBooleanValue_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("string_flag", new MixpanelFlagVariant("v1", "not_a_boolean")); + serverFlags.put("number_flag", new MixpanelFlagVariant("v2", 123)); + serverFlags.put("null_flag", new MixpanelFlagVariant("v3", null)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + assertTrue("String value should return fallback true", mFeatureFlagManager.isEnabledSync("string_flag", true)); + assertFalse("String value should return fallback false", mFeatureFlagManager.isEnabledSync("string_flag", false)); + + assertTrue("Number value should return fallback true", mFeatureFlagManager.isEnabledSync("number_flag", true)); + assertFalse("Number value should return fallback false", mFeatureFlagManager.isEnabledSync("number_flag", false)); + + assertTrue("Null value should return fallback true", mFeatureFlagManager.isEnabledSync("null_flag", true)); + assertFalse("Null value should return fallback false", mFeatureFlagManager.isEnabledSync("null_flag", false)); + } + + @Test + public void testIsEnabled_Async_flagsReady_booleanTrue() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("async_bool_true", new MixpanelFlagVariant("v_true", true)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled("async_bool_true", false, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return true for boolean true flag", resultRef.get()); + } + + @Test + public void testIsEnabled_Async_flagsReady_booleanFalse() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("async_bool_false", new MixpanelFlagVariant("v_false", false)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled("async_bool_false", true, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertFalse("Should return false for boolean false flag", resultRef.get()); + } + + @Test + public void testIsEnabled_Async_flagsNotReady_fetchSucceeds() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + Map serverFlags = new HashMap<>(); + serverFlags.put("fetch_bool_flag", new MixpanelFlagVariant("fetched", true)); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled("fetch_bool_flag", false, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return true after successful fetch", resultRef.get()); + assertTrue(mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testIsEnabled_Async_nonBooleanValue_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("string_async", new MixpanelFlagVariant("v_str", "hello")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled("string_async", true, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return fallback (true) for non-boolean value", resultRef.get()); + } + + @Test + public void testIsEnabled_Async_fetchFails_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + mMockRemoteService.addError(new IOException("Network error")); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled("fail_flag", true, result -> { + resultRef.set(result); + latch.countDown(); + }); + + assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return fallback (true) when fetch fails", resultRef.get()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testConcurrentLoadFlagsCalls() throws InterruptedException { + // Setup with flags enabled + setupFlagsConfig(true, new JSONObject()); + + // Track number of network requests made + final AtomicInteger requestCount = new AtomicInteger(0); + + // Prepare response data + Map serverFlags = new HashMap<>(); + serverFlags.put("concurrent_flag", new MixpanelFlagVariant("test_variant", "test_value")); + + // Create a custom MockRemoteService that counts requests and introduces delay + MockRemoteService customMockService = new MockRemoteService() { + @Override + public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, + Map params, Map headers, + byte[] requestBodyBytes, SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + // Count the request + requestCount.incrementAndGet(); + + // Introduce a delay to simulate network latency + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Return the prepared response + return super.performRequest(endpointUrl, interactor, params, headers, requestBodyBytes, socketFactory); + } + }; + + // Add response to the custom mock service + customMockService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Use reflection to set the custom mock service + try { + Field httpServiceField = FeatureFlagManager.class.getDeclaredField("mHttpService"); + httpServiceField.setAccessible(true); + httpServiceField.set(mFeatureFlagManager, customMockService); + } catch (Exception e) { + fail("Failed to set mock http service: " + e.getMessage()); + } + + // Number of concurrent threads + final int threadCount = 10; + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch completionLatch = new CountDownLatch(threadCount); + final List threads = new ArrayList<>(); + final AtomicInteger successCount = new AtomicInteger(0); + + // Create multiple threads that will call loadFlags concurrently + for (int i = 0; i < threadCount; i++) { + Thread thread = new Thread(() -> { + try { + // Wait for signal to start all threads simultaneously + startLatch.await(); + // Call loadFlags + mFeatureFlagManager.loadFlags(); + successCount.incrementAndGet(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + completionLatch.countDown(); + } + }); + threads.add(thread); + thread.start(); + } + + // Start all threads at the same time + startLatch.countDown(); + + // Wait for all threads to complete + assertTrue("All threads should complete within timeout", + completionLatch.await(5000, TimeUnit.MILLISECONDS)); + + // Wait a bit more for all loadFlags operations to complete + Thread.sleep(500); + + // Verify results + assertEquals("All threads should have completed successfully", threadCount, successCount.get()); + + // Only one network request should have been made despite multiple concurrent calls + // This verifies that loadFlags properly handles concurrent calls + assertEquals("Should only make one network request for concurrent loadFlags calls", + 1, requestCount.get()); + + // Verify flags are ready + assertTrue("Flags should be ready after concurrent loads", mFeatureFlagManager.areFlagsReady()); + + // Test accessing the flag synchronously + MixpanelFlagVariant variant = mFeatureFlagManager.getVariantSync("concurrent_flag", + new MixpanelFlagVariant("default")); + assertNotNull("Flag variant should not be null", variant); + assertEquals("test_variant", variant.key); + assertEquals("test_value", variant.value); + } + + @Test + public void testConcurrentGetVariantCalls_whenFlagsNotReady() throws InterruptedException { + // Setup with flags enabled + setupFlagsConfig(true, new JSONObject()); + + // Prepare response data that will be delayed + Map serverFlags = new HashMap<>(); + serverFlags.put("concurrent_get_flag1", new MixpanelFlagVariant("variant1", "value1")); + serverFlags.put("concurrent_get_flag2", new MixpanelFlagVariant("variant2", "value2")); + serverFlags.put("concurrent_get_flag3", new MixpanelFlagVariant("variant3", "value3")); + + // Create a mock service that introduces significant delay to simulate slow network + final AtomicInteger requestCount = new AtomicInteger(0); + MockRemoteService delayedMockService = new MockRemoteService() { + @Override + public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, + Map params, Map headers, + byte[] requestBodyBytes, SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + requestCount.incrementAndGet(); + // Introduce significant delay to ensure getVariant calls happen before flags are ready + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return super.performRequest(endpointUrl, interactor, params, headers, requestBodyBytes, socketFactory); + } + }; + + // Add response to the mock service + delayedMockService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Use reflection to set the custom mock service + try { + Field httpServiceField = FeatureFlagManager.class.getDeclaredField("mHttpService"); + httpServiceField.setAccessible(true); + httpServiceField.set(mFeatureFlagManager, delayedMockService); + } catch (Exception e) { + fail("Failed to set mock http service: " + e.getMessage()); + } + + // Trigger loadFlags which will be delayed + mFeatureFlagManager.loadFlags(); + + // Verify flags are not ready yet + assertFalse("Flags should not be ready immediately after loadFlags", mFeatureFlagManager.areFlagsReady()); + + // Number of concurrent threads calling getVariant + final int threadCount = 20; + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch completionLatch = new CountDownLatch(threadCount); + final List threads = new ArrayList<>(); + final Map results = new HashMap<>(); + final AtomicInteger successCount = new AtomicInteger(0); + + // Create multiple threads that will call getVariant concurrently while flags are loading + for (int i = 0; i < threadCount; i++) { + final int threadIndex = i; + final String flagName = "concurrent_get_flag" + ((i % 3) + 1); // Rotate through 3 different flags + final MixpanelFlagVariant fallback = new MixpanelFlagVariant("fallback" + threadIndex, "fallback_value" + threadIndex); + + Thread thread = new Thread(() -> { + try { + // Wait for signal to start all threads simultaneously + startLatch.await(); + + // Use async getVariant with callback + final CountDownLatch variantLatch = new CountDownLatch(1); + final AtomicReference variantRef = new AtomicReference<>(); + + mFeatureFlagManager.getVariant(flagName, fallback, variant -> { + variantRef.set(variant); + variantLatch.countDown(); + }); + + // Wait for callback + if (variantLatch.await(2000, TimeUnit.MILLISECONDS)) { + results.put(threadIndex, variantRef.get()); + successCount.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + completionLatch.countDown(); + } + }); + threads.add(thread); + thread.start(); + } + + // Start all threads at the same time (while flags are still loading) + startLatch.countDown(); + + // Wait for all threads to complete + assertTrue("All threads should complete within timeout", + completionLatch.await(3000, TimeUnit.MILLISECONDS)); + + // Verify results + assertEquals("All threads should have completed successfully", threadCount, successCount.get()); + + // Only one network request should have been made + assertEquals("Should only make one network request", 1, requestCount.get()); + + // Verify flags are now ready + assertTrue("Flags should be ready after all getVariant calls complete", mFeatureFlagManager.areFlagsReady()); + + // Verify all threads got the correct values (not fallbacks) + for (int i = 0; i < threadCount; i++) { + MixpanelFlagVariant result = results.get(i); + assertNotNull("Thread " + i + " should have a result", result); + + int flagIndex = (i % 3) + 1; + String expectedKey = "variant" + flagIndex; + String expectedValue = "value" + flagIndex; + + assertEquals("Thread " + i + " should have correct variant key", expectedKey, result.key); + assertEquals("Thread " + i + " should have correct variant value", expectedValue, result.value); + + // Verify it's not the fallback + assertNotEquals("Thread " + i + " should not have fallback key", "fallback" + i, result.key); + } + } + + @Test + public void testRequestBodyConstruction_performFetchRequest() throws InterruptedException, JSONException { + // Setup with flags enabled and specific context data + JSONObject contextData = new JSONObject(); + contextData.put("$os", "Android"); + contextData.put("$os_version", "13"); + contextData.put("custom_property", "test_value"); + setupFlagsConfig(true, contextData); + + // Set distinct ID for the request + mMockDelegate.distinctIdToReturn = "test_user_123"; + + // Create response data + Map serverFlags = new HashMap<>(); + serverFlags.put("test_flag", new MixpanelFlagVariant("variant_a", "value_a")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger loadFlags to initiate the request + mFeatureFlagManager.loadFlags(); + + // Capture the request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + // Verify the endpoint URL + assertTrue("URL should contain /flags endpoint", capturedRequest.endpointUrl.contains("/flags")); + + // Parse and verify the request body + assertNotNull("Request should have body", capturedRequest.requestBodyBytes); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + assertNotNull("Request body should be valid JSON", requestBody); + + // Log the actual request body for debugging + MPLog.v("FeatureFlagManagerTest", "Request body: " + requestBody.toString()); + + // Verify context is included + assertTrue("Request should contain context", requestBody.has("context")); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id is in the context + assertTrue("Context should contain distinct_id", requestContext.has("distinct_id")); + assertEquals("Context should contain correct distinct_id", + "test_user_123", requestContext.getString("distinct_id")); + + // Verify the context contains the expected properties from FlagsConfig + assertEquals("Context should contain $os", "Android", requestContext.getString("$os")); + assertEquals("Context should contain $os_version", "13", requestContext.getString("$os_version")); + assertEquals("Context should contain custom_property", "test_value", requestContext.getString("custom_property")); + + // Verify headers + assertNotNull("Request should have headers", capturedRequest.headers); + assertEquals("Content-Type should be application/json with charset", + "application/json; charset=utf-8", capturedRequest.headers.get("Content-Type")); + + // Wait for flags to be ready + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); i++) { + Thread.sleep(100); + } + assertTrue("Flags should be ready", mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testRequestBodyConstruction_withNullContext() throws InterruptedException, JSONException { + // Setup with flags enabled but null context + setupFlagsConfig(true, null); + + // Set distinct ID + mMockDelegate.distinctIdToReturn = "user_456"; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + // Parse request body + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + + // Verify context exists + assertTrue("Request should contain context", requestBody.has("context")); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id is in context + assertEquals("Context should contain correct distinct_id", "user_456", requestContext.getString("distinct_id")); + + // When FlagsConfig context is null, the context object should only contain distinct_id + assertEquals("Context should only contain distinct_id when FlagsConfig context is null", + 1, requestContext.length()); + } + + @Test + public void testRequestBodyConstruction_withEmptyDistinctId() throws InterruptedException, JSONException { + // Setup with flags enabled + setupFlagsConfig(true, new JSONObject()); + + // Set empty distinct ID + mMockDelegate.distinctIdToReturn = ""; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + // Parse request body + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + + // Verify context exists + assertTrue("Request should contain context", requestBody.has("context")); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id is included in context even when empty + assertTrue("Context should contain distinct_id field", requestContext.has("distinct_id")); + assertEquals("Context should contain empty distinct_id", "", requestContext.getString("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_initialContext() throws InterruptedException, JSONException { + // Test that initial context from FlagsConfig is properly used + JSONObject initialContext = new JSONObject(); + initialContext.put("app_version", "1.0.0"); + initialContext.put("platform", "Android"); + initialContext.put("custom_prop", "initial_value"); + + setupFlagsConfig(true, initialContext); + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("test_flag", new MixpanelFlagVariant("v1", "value1")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify all initial context properties are included + assertEquals("app_version should be preserved", "1.0.0", requestContext.getString("app_version")); + assertEquals("platform should be preserved", "Android", requestContext.getString("platform")); + assertEquals("custom_prop should be preserved", "initial_value", requestContext.getString("custom_prop")); + + // Verify distinct_id is added to context + assertTrue("distinct_id should be added to context", requestContext.has("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_contextMerging() throws InterruptedException, JSONException { + // Test that distinct_id doesn't override existing context properties + JSONObject initialContext = new JSONObject(); + initialContext.put("distinct_id", "should_be_overridden"); // This should be overridden + initialContext.put("user_type", "premium"); + initialContext.put("$os", "Android"); + + setupFlagsConfig(true, initialContext); + + // Set a different distinct_id via delegate + mMockDelegate.distinctIdToReturn = "actual_distinct_id"; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id from delegate overrides the one in initial context + assertEquals("distinct_id should be from delegate, not initial context", + "actual_distinct_id", requestContext.getString("distinct_id")); + + // Verify other properties are preserved + assertEquals("user_type should be preserved", "premium", requestContext.getString("user_type")); + assertEquals("$os should be preserved", "Android", requestContext.getString("$os")); + } + + @Test + public void testFlagsConfigContextUsage_emptyContext() throws InterruptedException, JSONException { + // Test behavior with empty context object + JSONObject emptyContext = new JSONObject(); + setupFlagsConfig(true, emptyContext); + + mMockDelegate.distinctIdToReturn = "test_user"; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Context should only contain distinct_id when initial context is empty + assertEquals("Context should only contain distinct_id", 1, requestContext.length()); + assertEquals("distinct_id should be present", "test_user", requestContext.getString("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_complexNestedContext() throws InterruptedException, JSONException { + // Test that complex nested objects in context are preserved + JSONObject nestedObj = new JSONObject(); + nestedObj.put("city", "San Francisco"); + nestedObj.put("country", "USA"); + + JSONObject initialContext = new JSONObject(); + initialContext.put("location", nestedObj); + initialContext.put("features_enabled", new JSONArray().put("feature1").put("feature2")); + initialContext.put("is_beta", true); + initialContext.put("score", 95.5); + + setupFlagsConfig(true, initialContext); + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify complex nested structures are preserved + JSONObject locationInRequest = requestContext.getJSONObject("location"); + assertEquals("city should be preserved", "San Francisco", locationInRequest.getString("city")); + assertEquals("country should be preserved", "USA", locationInRequest.getString("country")); + + JSONArray featuresInRequest = requestContext.getJSONArray("features_enabled"); + assertEquals("features array length should be preserved", 2, featuresInRequest.length()); + assertEquals("feature1 should be preserved", "feature1", featuresInRequest.getString(0)); + assertEquals("feature2 should be preserved", "feature2", featuresInRequest.getString(1)); + + assertTrue("is_beta should be preserved", requestContext.getBoolean("is_beta")); + assertEquals("score should be preserved", 95.5, requestContext.getDouble("score"), 0.001); + + // And distinct_id should still be added + assertTrue("distinct_id should be added", requestContext.has("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_specialCharactersInContext() throws InterruptedException, JSONException { + // Test that special characters and unicode in context are handled properly + JSONObject initialContext = new JSONObject(); + initialContext.put("emoji", "πŸš€πŸŽ‰"); + initialContext.put("special_chars", "!@#$%^&*()_+-=[]{}|;':\",./<>?"); + initialContext.put("unicode", "δ½ ε₯½δΈ–η•Œ"); + initialContext.put("newline", "line1\nline2"); + + setupFlagsConfig(true, initialContext); + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify special characters are preserved correctly + assertEquals("emoji should be preserved", "πŸš€πŸŽ‰", requestContext.getString("emoji")); + assertEquals("special_chars should be preserved", + "!@#$%^&*()_+-=[]{}|;':\",./<>?", requestContext.getString("special_chars")); + assertEquals("unicode should be preserved", "δ½ ε₯½δΈ–η•Œ", requestContext.getString("unicode")); + assertEquals("newline should be preserved", "line1\nline2", requestContext.getString("newline")); + } +} \ No newline at end of file diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java index f7aa2132..e1881719 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java @@ -4,6 +4,8 @@ import android.content.SharedPreferences; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; @@ -61,8 +63,15 @@ public void setUp() { final RemoteService mockPoster = new HttpService() { @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) - throws ServiceUnavailableException, IOException { + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException + { try { if (mFlushResults.isEmpty()) { mFlushResults.add(TestUtils.bytes("1\n")); diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java index b4822daf..56f64f41 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java @@ -5,6 +5,8 @@ import android.os.Build; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; @@ -22,6 +24,8 @@ import org.junit.Test; import org.junit.runner.RunWith; +import java.io.IOException; +import java.lang.reflect.Field; import java.net.URLEncoder; import java.util.ArrayList; import java.util.HashMap; @@ -703,7 +707,14 @@ public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { final RemoteService mockPoster = new HttpService() { @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) { + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + { final boolean isIdentified = isIdentifiedRef.get(); assertTrue(params.containsKey("data")); final String decoded = Base64Coder.decodeString(params.get("data").toString()); @@ -1162,26 +1173,67 @@ public void eventsMessage(EventDescription heard) { } }; + // Track calls to the flags endpoint + final List flagsEndpointCalls = new ArrayList<>(); + MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test Identify Call") { @Override protected AnalyticsMessages getAnalyticsMessages() { return listener; } + + @Override + protected RemoteService getHttpService() { + // Return a mock RemoteService that tracks calls to the flags endpoint + return new HttpService() { + @Override + public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, + Map params, Map headers, + byte[] requestBodyBytes, SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + // Track calls to the flags endpoint + if (endpointUrl != null && endpointUrl.contains("/flags/")) { + flagsEndpointCalls.add(endpointUrl); + } + // Return empty flags response + return "{\"flags\":{}}".getBytes(); + } + }; + } }; + String oldDistinctId = metrics.getDistinctId(); + + // Clear any flags calls from constructor + flagsEndpointCalls.clear(); + + // First identify should trigger loadFlags since distinctId changes metrics.identify(newDistinctId); + + // Give the async flag loading some time to execute + try { + Thread.sleep(500); + } catch (InterruptedException e) { + // Ignore + } + + // Second and third identify should NOT trigger loadFlags since distinctId doesn't change metrics.identify(newDistinctId); metrics.identify(newDistinctId); - assertEquals(messages.size(), 1); + // Verify that only one $identify event was tracked + assertEquals(1, messages.size()); AnalyticsMessages.EventDescription identifyEventDescription = messages.get(0); - assertEquals(identifyEventDescription.getEventName(), "$identify"); + assertEquals("$identify", identifyEventDescription.getEventName()); String newDistinctIdIdentifyTrack = identifyEventDescription.getProperties().getString("distinct_id"); String anonDistinctIdIdentifyTrack = identifyEventDescription.getProperties().getString("$anon_distinct_id"); - assertEquals(newDistinctIdIdentifyTrack, newDistinctId); - assertEquals(anonDistinctIdIdentifyTrack, oldDistinctId); - assertEquals(messages.size(), 1); + assertEquals(newDistinctId, newDistinctIdIdentifyTrack); + assertEquals(oldDistinctId, anonDistinctIdIdentifyTrack); + + // Assert that loadFlags was called (flags endpoint was hit) when distinctId changed + assertTrue("loadFlags should have been called when distinctId changed", + flagsEndpointCalls.size() >= 1); } @Test @@ -1398,7 +1450,14 @@ protected AnalyticsMessages getAnalyticsMessages() { public void testAlias() { final RemoteService mockPoster = new HttpService() { @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) { + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + { try { assertTrue(params.containsKey("data")); final String jsonData = Base64Coder.decodeString(params.get("data").toString()); diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java index a2e4b98d..d88a4420 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java @@ -3,6 +3,8 @@ import android.content.Context; import android.content.SharedPreferences; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; @@ -58,7 +60,14 @@ public void setUp() { final RemoteService mockPoster = new HttpService() { @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) { + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + { if (params != null) { final String jsonData = Base64Coder.decodeString(params.get("data").toString()); assertTrue(params.containsKey("data")); diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java index fc077930..becf18f3 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java @@ -21,11 +21,13 @@ public static byte[] bytes(String s) { public static class CleanMixpanelAPI extends MixpanelAPI { public CleanMixpanelAPI(final Context context, final Future referrerPreferences, final String token, final boolean trackAutomaticEvents) { - super(context, referrerPreferences, token, false, null, trackAutomaticEvents); + super(context, referrerPreferences, token, MPConfig.getInstance(context, null), + new MixpanelOptions.Builder().featureFlagsEnabled(true).build(), trackAutomaticEvents); } public CleanMixpanelAPI(final Context context, final Future referrerPreferences, final String token) { - super(context, referrerPreferences, token, false, null, false); + super(context, referrerPreferences, token, MPConfig.getInstance(context, null), + new MixpanelOptions.Builder().featureFlagsEnabled(true).build(), false); } public CleanMixpanelAPI(final Context context, final Future referrerPreferences, final String token, final String instanceName) { diff --git a/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java b/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java index 0c621ce6..69b51df3 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java @@ -518,7 +518,7 @@ private void sendData(MPDbAdapter dbAdapter, String token, MPDbAdapter.Table tab byte[] response; try { final SSLSocketFactory socketFactory = mConfig.getSSLSocketFactory(); - response = poster.performRequest(url, mConfig.getProxyServerInteractor(), params, socketFactory); + response = poster.performRequest(url, mConfig.getProxyServerInteractor(), params, null, null, socketFactory); if (null == response) { deleteEvents = false; logAboutMessageToMixpanel("Response was null, unexpected failure posting to " + url + "."); diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagDelegate.java b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagDelegate.java new file mode 100644 index 00000000..d79a9752 --- /dev/null +++ b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagDelegate.java @@ -0,0 +1,14 @@ +package com.mixpanel.android.mpmetrics; + +import org.json.JSONObject; + +/** + * Interface for FeatureFlagManager to retrieve necessary data and trigger actions + * from the main MixpanelAPI instance. + */ +interface FeatureFlagDelegate { + MPConfig getMPConfig(); + String getDistinctId(); + void track(String eventName, JSONObject properties); + String getToken(); +} \ No newline at end of file diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java new file mode 100644 index 00000000..33914411 --- /dev/null +++ b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java @@ -0,0 +1,663 @@ +package com.mixpanel.android.mpmetrics; + +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.mixpanel.android.util.Base64Coder; +import com.mixpanel.android.util.JsonUtils; +import com.mixpanel.android.util.MPLog; +import com.mixpanel.android.util.RemoteService; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +class FeatureFlagManager implements MixpanelAPI.Flags { + private static final String LOGTAG = "MixpanelAPI.FeatureFlagManager"; + + private final WeakReference mDelegate; + private final FlagsConfig mFlagsConfig; + private final String mFlagsEndpoint; // e.g. https://api.mixpanel.com/flags/ + private final RemoteService mHttpService; // Use RemoteService interface + private final FeatureFlagHandler mHandler; // For serializing state access and operations + private final ExecutorService mNetworkExecutor; // For performing network calls off the handler thread + private final Object mLock = new Object(); + + // --- State Variables (Protected by mHandler) --- + private volatile Map mFlags = null; + private final Set mTrackedFlags = new HashSet<>(); + private boolean mIsFetching = false; + private List> mFetchCompletionCallbacks = new ArrayList<>(); + // --- + + // Message codes for Handler + private static final int MSG_FETCH_FLAGS_IF_NEEDED = 0; + private static final int MSG_COMPLETE_FETCH = 1; + + public FeatureFlagManager( + @NonNull FeatureFlagDelegate delegate, + @NonNull RemoteService httpService, + @NonNull FlagsConfig flagsConfig + ) { + mDelegate = new WeakReference<>(delegate); + mFlagsEndpoint = delegate.getMPConfig().getFlagsEndpoint(); + mHttpService = httpService; + mFlagsConfig = flagsConfig; + + // Dedicated thread for serializing access to flags state + HandlerThread handlerThread = new HandlerThread("com.mixpanel.android.FeatureFlagManagerWorker", Thread.MIN_PRIORITY); + handlerThread.start(); + mHandler = new FeatureFlagHandler(handlerThread.getLooper()); + + // Separate executor for network requests so they don't block the state queue + mNetworkExecutor = Executors.newSingleThreadExecutor(); + } + + // --- Public Methods --- + + /** + * Asynchronously loads flags from the Mixpanel server if they haven't been loaded yet + */ + public void loadFlags() { + // Send message to the handler thread to check and potentially fetch + mHandler.sendMessage(mHandler.obtainMessage(MSG_FETCH_FLAGS_IF_NEEDED)); + } + + /** + * Returns true if flags are loaded and ready for synchronous access. + */ + public boolean areFlagsReady() { + synchronized (mLock) { + return mFlags != null; + } + } + + // --- Sync Flag Retrieval --- + + /** + * Gets the feature flag variant (key and value) synchronously. + * IMPORTANT: This method will block the calling thread until the value can be + * retrieved. It is NOT recommended to call this from the main UI thread + * if flags might not be ready. If flags are not ready (`areFlagsReady()` is false), + * it returns the fallback immediately without blocking or fetching. + * + * @param flagName The name of the feature flag. + * @param fallback The MixpanelFlagVariant instance to return if the flag is not found or not ready. + * @return The found MixpanelFlagVariant or the fallback. + */ + @NonNull + public MixpanelFlagVariant getVariantSync(@NonNull final String flagName, @NonNull final MixpanelFlagVariant fallback) { + // 1. Check readiness first - don't block if flags aren't loaded. + if (!areFlagsReady()) { + MPLog.w(LOGTAG, "Flags not ready for getVariantSync call for '" + flagName + "'. Returning fallback."); + return fallback; + } + + // Use a container to get results back from the handler thread runnable + final var resultContainer = new Object() { + MixpanelFlagVariant flagVariant = null; + boolean tracked = false; + }; + + // 2. Execute the core logic synchronously on the handler thread + mHandler.runAndWait(() -> { + // We are now on the mHandler thread. areFlagsReady() was true, but check mFlags again for safety. + if (mFlags == null) { // Should not happen if areFlagsReady was true, but defensive check + MPLog.w(LOGTAG, "Flags became null unexpectedly in getVariantSync runnable."); + return; // Keep resultContainer.flagVariant as null + } + + MixpanelFlagVariant variant = mFlags.get(flagName); + if (variant != null) { + resultContainer.flagVariant = variant; + + // Perform atomic check-and-set for tracking directly here + // (Calls _checkAndSetTrackedFlag which runs on this thread) + resultContainer.tracked = _checkAndSetTrackedFlag(flagName); + } + // If variant is null, resultContainer.flagVariant remains null + }); + + // 3. Process results after handler block completes + + if (resultContainer.flagVariant != null) { + if (resultContainer.tracked) { + // If tracking was performed *in this call*, trigger the delegate call helper + // (This runs on the *calling* thread, but _performTrackingDelegateCall dispatches to main) + _performTrackingDelegateCall(flagName, resultContainer.flagVariant); + } + return resultContainer.flagVariant; + } else { + // Flag key not found in the loaded flags + MPLog.i(LOGTAG, "Flag '" + flagName + "' not found sync. Returning fallback."); + return fallback; + } + } + + /** + * Gets the value of a feature flag synchronously. + * IMPORTANT: See warning on getVariantSync regarding blocking and readiness checks. + * Returns fallback immediately if flags are not ready. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default value to return if the flag is missing or not ready. + * @return The flag's value (Object or null) or the fallbackValue. + */ + @Nullable + public Object getVariantValueSync(@NonNull String flagName, @Nullable Object fallbackValue) { + MixpanelFlagVariant fallbackVariant = new MixpanelFlagVariant("", fallbackValue); + MixpanelFlagVariant resultVariant = getVariantSync(flagName, fallbackVariant); + // If getVariantSync returned the *original* fallbackValue, its value is fallbackValue. + // If getVariantSync returned a *real* flag, its value is resultVariant.value. + return resultVariant.value; + } + + /** + * Checks if a feature flag is enabled synchronously (evaluates value as boolean). + * IMPORTANT: See warning on getVariantSync regarding blocking and readiness checks. + * Returns fallbackValue immediately if flags are not ready. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default boolean value if the flag is missing, not boolean, or not ready. + * @return True if the flag evaluates to true, false otherwise or if fallbackValue is returned. + */ + public boolean isEnabledSync(@NonNull String flagName, boolean fallbackValue) { + Object variantValue = getVariantValueSync(flagName, fallbackValue); + return _evaluateBooleanFlag(flagName, variantValue, fallbackValue); + } + + /** + * Asynchronously gets the feature flag variant (key and value). + * If flags are not loaded, it triggers a fetch. + * Completion handler is called on the main thread. + * + * @param flagName The name of the feature flag. + * @param fallback The MixpanelFlagVariant instance to return if the flag is not found or fetch fails. + * @param completion The callback to receive the result. + */ + public void getVariant( + @NonNull final String flagName, + @NonNull final MixpanelFlagVariant fallback, + @NonNull final FlagCompletionCallback completion + ) { + // Post the core logic to the handler thread for safe state access + mHandler.post(() -> { // Block A: Initial processing, runs serially on mHandler thread + MixpanelFlagVariant flagVariant; + boolean needsTracking; + boolean flagsAreCurrentlyReady = (mFlags != null); + + if (flagsAreCurrentlyReady) { + // --- Flags ARE Ready --- + MPLog.v(LOGTAG, "Flags ready. Checking for flag '" + flagName + "'"); + flagVariant = mFlags.get(flagName); // Read state directly (safe on handler thread) + + if (flagVariant != null) { + needsTracking = _checkAndSetTrackedFlag(flagName); // Runs on handler thread + } else { + needsTracking = false; + } + + MixpanelFlagVariant result = (flagVariant != null) ? flagVariant : fallback; + MPLog.v(LOGTAG, "Found flag variant (or fallback): " + result.key + " -> " + result.value); + + // Dispatch completion and potential tracking to main thread + new Handler(Looper.getMainLooper()).post(() -> { // Block B: User completion and subsequent tracking logic, runs on Main Thread + completion.onComplete(result); + if (flagVariant != null && needsTracking) { + MPLog.v(LOGTAG, "Tracking needed for '" + flagName + "'."); + // _performTrackingDelegateCall handles its own main thread dispatch for the delegate. + _performTrackingDelegateCall(flagName, result); + } + }); // End Block B (Main Thread) + + + } else { + // --- Flags were NOT Ready --- + MPLog.i(LOGTAG, "Flags not ready, attempting fetch for getVariant call '" + flagName + "'..."); + _fetchFlagsIfNeeded(success -> { + // This fetch completion block itself runs on the MAIN thread (due to postCompletion in _completeFetch) + MPLog.v(LOGTAG, "Fetch completion received on main thread for '" + flagName + "'. Success: " + success); + if (success) { + // Fetch succeeded. Post BACK to the handler thread to get the flag value + // and perform tracking check now that flags are ready. + mHandler.post(() -> { // Block C: Post-fetch processing, runs on mHandler thread + MPLog.v(LOGTAG, "Processing successful fetch result for '" + flagName + "' on handler thread."); + MixpanelFlagVariant fetchedVariant = mFlags != null ? mFlags.get(flagName) : null; + boolean tracked; + if (fetchedVariant != null) { + tracked = _checkAndSetTrackedFlag(flagName); + } else { + tracked = false; + } + MixpanelFlagVariant finalResult = (fetchedVariant != null) ? fetchedVariant : fallback; + + // Dispatch final user completion and potential tracking to main thread + new Handler(Looper.getMainLooper()).post(() -> { // Block D: User completion and subsequent tracking, runs on Main Thread + completion.onComplete(finalResult); + if (fetchedVariant != null && tracked) { + _performTrackingDelegateCall(flagName, finalResult); + } + }); // End Block D (Main Thread) + }); // End Block C (handler thread) + } else { + // Fetch failed, just call original completion with fallback (already on main thread) + MPLog.w(LOGTAG, "Fetch failed for '" + flagName + "'. Returning fallback."); + completion.onComplete(fallback); + } + }); // End _fetchFlagsIfNeeded completion + // No return here needed as _fetchFlagsIfNeeded's completion handles the original callback + } + }); // End mHandler.post (Block A) + } + + /** + * Asynchronously gets the value of a feature flag. + * If flags are not loaded, it triggers a fetch. + * Completion handler is called on the main thread. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default value to return if the flag is missing or fetch fails. + * @param completion The callback to receive the result value (Object or null). + */ + public void getVariantValue( + @NonNull final String flagName, + @Nullable final Object fallbackValue, + @NonNull final FlagCompletionCallback completion + ) { + // Create a fallback MixpanelFlagVariant. Using empty key as it's not relevant here. + MixpanelFlagVariant fallbackVariant = new MixpanelFlagVariant("", fallbackValue); + // Call getVariant and extract the value in its completion handler + getVariant(flagName, fallbackVariant, result -> completion.onComplete(result.value)); + } + + + /** + * Asynchronously checks if a feature flag is enabled (evaluates value as boolean). + * If flags are not loaded, it triggers a fetch. + * Completion handler is called on the main thread. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default boolean value if the flag is missing, not boolean, or fetch fails. + * @param completion The callback to receive the boolean result. + */ + public void isEnabled( + @NonNull final String flagName, + final boolean fallbackValue, + @NonNull final FlagCompletionCallback completion + ) { + // Call getVariantValue, using the boolean fallbackValue as the fallback too + // (this ensures if the flag is missing, evaluateBoolean gets the intended fallback) + getVariantValue(flagName, fallbackValue, value -> { + // This completion runs on the main thread + boolean isEnabled = _evaluateBooleanFlag(flagName, value, fallbackValue); + completion.onComplete(isEnabled); + }); + } + + + // --- Handler --- + + private class FeatureFlagHandler extends Handler { + public FeatureFlagHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(@NonNull Message msg) { + switch (msg.what) { + case MSG_FETCH_FLAGS_IF_NEEDED: + _fetchFlagsIfNeeded(null); // Assume completion passed via instance var now + break; + + case MSG_COMPLETE_FETCH: + // Extract results from the Message Bundle + Bundle data = msg.getData(); + boolean success = data.getBoolean("success"); + String responseJsonString = data.getString("responseJson"); // Can be null + String errorMessage = data.getString("errorMessage"); // Can be null + + JSONObject responseJson = null; + if (success && responseJsonString != null) { + try { + responseJson = new JSONObject(responseJsonString); + } catch (JSONException e) { + MPLog.e(LOGTAG, "Could not parse response JSON string in completeFetch", e); + success = false; // Treat parse failure as overall failure + errorMessage = "Failed to parse flags response JSON."; + } + } + if (!success && errorMessage != null) { + MPLog.w(LOGTAG, "Flag fetch failed: " + errorMessage); + } + // Call the internal completion logic + _completeFetch(success, responseJson); + break; + + default: + MPLog.e(LOGTAG, "Unknown message type " + msg.what); + } + } + + /** + * Executes a Runnable synchronously on this handler's thread. + * Blocks the calling thread until the Runnable completes. + * Handles being called from the handler's own thread to prevent deadlock. + * @param r The Runnable to execute. + */ + public void runAndWait(Runnable r) { + if (Thread.currentThread() == getLooper().getThread()) { + // Already on the handler thread, run directly + r.run(); + } else { + // Use CountDownLatch to wait for completion + final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + post(() -> { // Post the task to the handler thread + try { + r.run(); + } finally { + latch.countDown(); // Signal completion even if Runnable throws + } + }); + + // Wait for the latch + try { + latch.await(); + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Interrupted waiting for handler task", e); + Thread.currentThread().interrupt(); // Preserve interrupt status + } + } + } + } + + // --- Internal Methods (run on Handler thread or background executor) --- + + // Runs on Handler thread + private void _fetchFlagsIfNeeded(@Nullable FlagCompletionCallback completion) { + // It calls _performFetchRequest via mNetworkExecutor if needed. + var shouldStartFetch = false; + + if (!mFlagsConfig.enabled) { + MPLog.i(LOGTAG, "Feature flags are disabled, not fetching."); + postCompletion(completion, false); + return; + } + + if (!mIsFetching) { + mIsFetching = true; + shouldStartFetch = true; + if (completion != null) { + mFetchCompletionCallbacks.add(completion); + } + } else { + MPLog.d(LOGTAG, "Fetch already in progress, queueing completion handler."); + if (completion != null) { + mFetchCompletionCallbacks.add(completion); + } + } + + if (shouldStartFetch) { + MPLog.d(LOGTAG, "Starting flag fetch (dispatching network request)..."); + mNetworkExecutor.execute(this::_performFetchRequest); + } + } + + // Runs on Network Executor thread + /** + * Performs the actual network request on the mNetworkExecutor thread. + * Constructs the request, sends it, and posts the result (success/failure + data) + * back to the mHandler thread via MSG_COMPLETE_FETCH. + */ + private void _performFetchRequest() { + MPLog.v(LOGTAG, "Performing fetch request on thread: " + Thread.currentThread().getName()); + boolean success = false; + JSONObject responseJson = null; // To hold parsed successful response + String errorMessage = "Delegate or config not available"; // Default error + + final FeatureFlagDelegate delegate = mDelegate.get(); + if (delegate == null) { + MPLog.w(LOGTAG, "Delegate became null before network request could start."); + postResultToHandler(false, null, errorMessage); + return; + } + + final MPConfig config = delegate.getMPConfig(); + final String distinctId = delegate.getDistinctId(); + + if (distinctId == null) { + MPLog.w(LOGTAG, "Distinct ID is null. Cannot fetch flags."); + errorMessage = "Distinct ID is null."; + postResultToHandler(false, null, errorMessage); + return; + } + + try { + // 1. Build Request Body JSON + JSONObject contextJson = new JSONObject(mFlagsConfig.context.toString()); + contextJson.put("distinct_id", distinctId); + JSONObject requestJson = new JSONObject(); + requestJson.put("context", contextJson); + String requestJsonString = requestJson.toString(); + MPLog.v(LOGTAG, "Request JSON Body: " + requestJsonString); + byte[] requestBodyBytes = requestJsonString.getBytes(StandardCharsets.UTF_8); // Get raw bytes + + + // 3. Build Headers + String token = delegate.getToken(); // Assuming token is in MPConfig + if (token == null || token.trim().isEmpty()) { + throw new IOException("Mixpanel token is missing or empty."); + } + String authString = token + ":"; + String base64Auth = Base64Coder.encodeString(authString); + Map headers = new HashMap<>(); + headers.put("Authorization", "Basic " + base64Auth); + headers.put("Content-Type", "application/json; charset=utf-8"); // Explicitly set content type + + // 4. Perform Request + byte[] responseBytes = mHttpService.performRequest( // <-- Use consolidated method + mFlagsEndpoint, + config.getProxyServerInteractor(), + null, // Pass null for params when sending raw body + headers, + requestBodyBytes, // Pass raw JSON body bytes + config.getSSLSocketFactory() + ); + + // 5. Process Response + if (responseBytes == null) { + errorMessage = "Received non-successful HTTP status or null response from flags endpoint."; + MPLog.w(LOGTAG, errorMessage); + } else { + try { + String responseString = new String(responseBytes, "UTF-8"); + MPLog.v(LOGTAG, "Flags response: " + responseString); + responseJson = new JSONObject(responseString); + if (responseJson.has("error")) { + errorMessage = "Mixpanel API returned error: " + responseJson.getString("error"); + MPLog.e(LOGTAG, errorMessage); + // Keep success = false + } else { + success = true; // Parsed JSON successfully and no 'error' field + } + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 not supported on this platform?", e); // Should not happen + } catch (JSONException e) { + errorMessage = "Could not parse Mixpanel flags response"; + MPLog.e(LOGTAG, errorMessage, e); + // Keep success = false + } + } + } catch (RemoteService.ServiceUnavailableException e) { + errorMessage = "Mixpanel service unavailable"; + MPLog.w(LOGTAG, errorMessage, e); + // TODO: Implement retry logic / backoff based on e.getRetryAfter() if needed? + // For now, just fail the fetch completely for simplicity. + } catch (MalformedURLException e) { + errorMessage = "Flags endpoint URL is malformed: " + mFlagsEndpoint; + MPLog.e(LOGTAG, errorMessage, e); + } catch (IOException e) { + errorMessage = "Network error while fetching flags"; + MPLog.e(LOGTAG, errorMessage, e); + } catch (JSONException e) { + errorMessage = "Failed to construct request JSON"; + MPLog.e(LOGTAG, errorMessage, e); + } catch (Exception e) { // Catch unexpected errors + errorMessage = "Unexpected error during flag fetch"; + MPLog.e(LOGTAG, errorMessage, e); + } + + // 6. Post result back to Handler thread + postResultToHandler(success, responseJson, errorMessage); + } + + /** + * Helper to dispatch the result of the fetch back to the handler thread. + */ + private void postResultToHandler(boolean success, @Nullable JSONObject responseJson, @Nullable String errorMessage) { + // Use a Bundle to pass multiple arguments efficiently + android.os.Bundle resultData = new android.os.Bundle(); + resultData.putBoolean("success", success); + if (success && responseJson != null) { + resultData.putString("responseJson", responseJson.toString()); + } else if (!success && errorMessage != null) { + resultData.putString("errorMessage", errorMessage); + } + + Message msg = mHandler.obtainMessage(MSG_COMPLETE_FETCH); + msg.setData(resultData); + mHandler.sendMessage(msg); + } + + // Runs on Handler thread + /** + * Centralized fetch completion logic. Runs on the Handler thread. + * Updates state and calls completion handlers. + * + * @param success Whether the fetch was successful. + * @param flagsResponseJson The parsed JSON object from the response, or null if fetch failed or parsing failed. + */ + @VisibleForTesting + void _completeFetch(boolean success, @Nullable JSONObject flagsResponseJson) { + MPLog.d(LOGTAG, "Completing fetch request. Success: " + success); + // State updates MUST happen on the handler thread implicitly + mIsFetching = false; + + List> callbacksToCall = mFetchCompletionCallbacks; + mFetchCompletionCallbacks = new ArrayList<>(); + + if (success && flagsResponseJson != null) { + Map newFlags = JsonUtils.parseFlagsResponse(flagsResponseJson); + synchronized (mLock) { + mFlags = Collections.unmodifiableMap(newFlags); + } + MPLog.v(LOGTAG, "Flags updated: " + mFlags.size() + " flags loaded."); + } else { + MPLog.w(LOGTAG, "Flag fetch failed or response missing/invalid. Keeping existing flags (if any)."); + } + + // Call handlers outside the state update logic, dispatch to main thread + if (!callbacksToCall.isEmpty()) { + MPLog.d(LOGTAG, "Calling " + callbacksToCall.size() + " fetch completion handlers."); + for(FlagCompletionCallback callback : callbacksToCall) { + postCompletion(callback, success); + } + } else { + MPLog.d(LOGTAG, "No fetch completion handlers to call."); + } + } + + /** + * Atomically checks if a feature flag has been tracked and marks it as tracked if not. + * MUST be called from the mHandler thread. + * + * @param flagName The name of the feature flag. + * @return true if the flag was NOT previously tracked (and was therefore marked now), false otherwise. + */ + private boolean _checkAndSetTrackedFlag(@NonNull String flagName) { + // Already running on the handler thread, direct access is safe and serialized + if (!mTrackedFlags.contains(flagName)) { + mTrackedFlags.add(flagName); + return true; // Needs tracking + } + return false; // Already tracked + } + + /** + * Constructs the $experiment_started event properties and dispatches + * the track call to the main thread via the delegate. + * This method itself does NOT need to run on the handler thread, but is typically + * called after a check that runs on the handler thread (_trackFeatureIfNeeded). + * + * @param flagName Name of the feature flag. + * @param variant The specific variant received. + */ + private void _performTrackingDelegateCall(String flagName, MixpanelFlagVariant variant) { + final FeatureFlagDelegate delegate = mDelegate.get(); + if (delegate == null) { + MPLog.w(LOGTAG, "Delegate is null, cannot track $experiment_started."); + return; + } + + // Construct properties + JSONObject properties = new JSONObject(); + try { + properties.put("Experiment name", flagName); + properties.put("Variant name", variant.key); // Use the variant key + properties.put("$experiment_type", "feature_flag"); + } catch (JSONException e) { + MPLog.e(LOGTAG, "Failed to create JSON properties for $experiment_started event", e); + return; // Don't track if properties failed + } + + MPLog.v(LOGTAG, "Queueing $experiment_started event for dispatch: " + properties.toString()); + + // Dispatch delegate call asynchronously to main thread for safety + new Handler(Looper.getMainLooper()).post(() -> { + // Re-fetch delegate inside main thread runnable just in case? Usually not necessary. + final FeatureFlagDelegate currentDelegate = mDelegate.get(); + if (currentDelegate != null) { + currentDelegate.track("$experiment_started", properties); + MPLog.v(LOGTAG, "Tracked $experiment_started for " + flagName + " (dispatched to main)"); + } else { + MPLog.w(LOGTAG, "Delegate was null when track call executed on main thread."); + } + }); + } + + // Helper to post completion callbacks to the main thread + private void postCompletion(@Nullable final FlagCompletionCallback callback, final T result) { + if (callback != null) { + new Handler(Looper.getMainLooper()).post(() -> callback.onComplete(result)); + } + } + + // --- Boolean Evaluation Helper --- + private boolean _evaluateBooleanFlag(String flagName, Object variantValue, boolean fallbackValue) { + if (variantValue instanceof Boolean) { + return (Boolean) variantValue; + } + MPLog.w(LOGTAG,"Flag value for " + flagName + " not boolean: " + variantValue); + return fallbackValue; + } +} \ No newline at end of file diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FlagCompletionCallback.java b/src/main/java/com/mixpanel/android/mpmetrics/FlagCompletionCallback.java new file mode 100644 index 00000000..a96e020e --- /dev/null +++ b/src/main/java/com/mixpanel/android/mpmetrics/FlagCompletionCallback.java @@ -0,0 +1,5 @@ +package com.mixpanel.android.mpmetrics; + +public interface FlagCompletionCallback { + void onComplete(T result); +} diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FlagsConfig.java b/src/main/java/com/mixpanel/android/mpmetrics/FlagsConfig.java new file mode 100644 index 00000000..0bf7e26b --- /dev/null +++ b/src/main/java/com/mixpanel/android/mpmetrics/FlagsConfig.java @@ -0,0 +1,28 @@ +package com.mixpanel.android.mpmetrics; + +import androidx.annotation.NonNull; + +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +class FlagsConfig { + public final boolean enabled; + @NonNull public final JSONObject context; + + public FlagsConfig() { + this.enabled = false; + this.context = new JSONObject(); + } + + public FlagsConfig(boolean enabled) { + this.enabled = enabled; + this.context = new JSONObject(); + } + + public FlagsConfig(boolean enabled, @NonNull JSONObject context) { + this.enabled = enabled; + this.context = context; + } +} diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java b/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java index c15fed50..315cf5f8 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java @@ -14,6 +14,8 @@ import com.mixpanel.android.util.OfflineMode; import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.Map; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; @@ -75,6 +77,9 @@ *
com.mixpanel.android.MPConfig.GroupsEndpoint
*
A string URL. If present, the library will attempt to send group updates to this endpoint rather than to the default Mixpanel endpoint.
* + *
com.mixpanel.android.MPConfig.FlagsEndpoint
+ *
A string URL. If present, the library will attempt to fetch feature flags from this endpoint rather than to the default Mixpanel endpoint.
+ * *
com.mixpanel.android.MPConfig.MinimumSessionDuration
*
An integer number. The minimum session duration (ms) that is tracked in automatic events. Defaults to 10000 (10 seconds).
* @@ -251,6 +256,13 @@ public synchronized void setOfflineMode(OfflineMode offlineMode) { setGroupsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); } + String flagsEndpoint = metaData.getString("com.mixpanel.android.MPConfig.FlagsEndpoint"); + if (flagsEndpoint != null) { + setFlagsEndpoint(flagsEndpoint); + } else { + setFlagsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); + } + MPLog.v(LOGTAG, toString()); } @@ -307,6 +319,10 @@ public String getEventsEndpoint() { return mEventsEndpoint; } + public String getFlagsEndpoint() { + return mFlagsEndpoint; + } + public boolean getTrackAutomaticEvents() { return mTrackAutomaticEvents; } public void setServerURL(String serverURL, ProxyServerInteractor interactor) { @@ -319,6 +335,7 @@ public void setServerURL(String serverURL) { setEventsEndpointWithBaseURL(serverURL); setPeopleEndpointWithBaseURL(serverURL); setGroupsEndpointWithBaseURL(serverURL); + setFlagsEndpointWithBaseURL(serverURL); } private String getEndPointWithIpTrackingParam(String endPoint, boolean ifUseIpAddressForGeolocation) { @@ -363,6 +380,14 @@ private void setGroupsEndpoint(String groupsEndpoint) { mGroupsEndpoint = groupsEndpoint; } + private void setFlagsEndpointWithBaseURL(String baseURL) { + setFlagsEndpoint(baseURL + MPConstants.URL.FLAGS); + } + + private void setFlagsEndpoint(String flagsEndpoint) { + mFlagsEndpoint = flagsEndpoint; + } + public int getMinimumSessionDuration() { return mMinSessionDuration; } @@ -461,6 +486,8 @@ public String toString() { " EnableDebugLogging " + DEBUG + "\n" + " EventsEndpoint " + getEventsEndpoint() + "\n" + " PeopleEndpoint " + getPeopleEndpoint() + "\n" + + " GroupsEndpoint " + getGroupsEndpoint() + "\n" + + " FlagsEndpoint " + getFlagsEndpoint() + "\n" + " MinimumSessionDuration: " + getMinimumSessionDuration() + "\n" + " SessionTimeoutDuration: " + getSessionTimeoutDuration() + "\n" + " DisableExceptionHandler: " + getDisableExceptionHandler() + "\n" + @@ -480,6 +507,7 @@ public String toString() { private String mEventsEndpoint; private String mPeopleEndpoint; private String mGroupsEndpoint; + private String mFlagsEndpoint; private int mFlushBatchSize; private boolean shouldGzipRequestPayload; diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java index ddd6ebc5..c2e8bfec 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java @@ -12,11 +12,16 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.MPLog; import com.mixpanel.android.util.MixpanelNetworkErrorListener; import com.mixpanel.android.util.ProxyServerInteractor; +import com.mixpanel.android.util.RemoteService; import org.json.JSONArray; import org.json.JSONException; @@ -101,7 +106,7 @@ * @see getting started documentation for People Analytics * @see The Mixpanel Android sample application */ -public class MixpanelAPI { +public class MixpanelAPI implements FeatureFlagDelegate { /** * String version of the library. */ @@ -120,7 +125,7 @@ public class MixpanelAPI { * Use MixpanelAPI.getInstance to get an instance. */ MixpanelAPI(Context context, Future referrerPreferences, String token, boolean optOutTrackingDefault, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { - this(context, referrerPreferences, token, MPConfig.getInstance(context, instanceName), optOutTrackingDefault, superProperties, instanceName, trackAutomaticEvents); + this(context, referrerPreferences, token, MPConfig.getInstance(context, instanceName), optOutTrackingDefault, superProperties, instanceName, trackAutomaticEvents); } /** @@ -128,9 +133,24 @@ public class MixpanelAPI { * Use MixpanelAPI.getInstance to get an instance. */ MixpanelAPI(Context context, Future referrerPreferences, String token, MPConfig config, boolean optOutTrackingDefault, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { + this( + context, + referrerPreferences, + token, + config, + new MixpanelOptions.Builder().optOutTrackingDefault(optOutTrackingDefault).superProperties(superProperties).instanceName(instanceName).build(), + trackAutomaticEvents + ); + } + + /** + * You shouldn't instantiate MixpanelAPI objects directly. + * Use MixpanelAPI.getInstance to get an instance. + */ + MixpanelAPI(Context context, Future referrerPreferences, String token, MPConfig config, MixpanelOptions options, boolean trackAutomaticEvents) { mContext = context; mToken = token; - mInstanceName = instanceName; + mInstanceName = options.getInstanceName(); mPeople = new PeopleImpl(); mGroups = new HashMap(); mConfig = config; @@ -155,15 +175,23 @@ public class MixpanelAPI { mSessionMetadata = new SessionMetadata(); mMessages = getAnalyticsMessages(); - mPersistentIdentity = getPersistentIdentity(context, referrerPreferences, token, instanceName); + mPersistentIdentity = getPersistentIdentity(context, referrerPreferences, token, options.getInstanceName()); mEventTimings = mPersistentIdentity.getTimeEvents(); - if (optOutTrackingDefault && (hasOptedOutTracking() || !mPersistentIdentity.hasOptOutFlag(token))) { + mFeatureFlagManager = new FeatureFlagManager( + this, + getHttpService(), + new FlagsConfig(options.areFeatureFlagsEnabled(), options.getFeatureFlagsContext()) + ); + + mFeatureFlagManager.loadFlags(); + + if (options.isOptOutTrackingDefault() && (hasOptedOutTracking() || !mPersistentIdentity.hasOptOutFlag(token))) { optOutTracking(); } - if (superProperties != null) { - registerSuperProperties(superProperties); + if (options.getSuperProperties() != null) { + registerSuperProperties(options.getSuperProperties()); } final boolean dbExists = MPDbAdapter.getInstance(mContext, mConfig).getDatabaseFile().exists(); @@ -411,7 +439,6 @@ public static MixpanelAPI getInstance(Context context, String token, JSONObject public static MixpanelAPI getInstance(Context context, String token, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { return getInstance(context, token, false, superProperties, instanceName, trackAutomaticEvents); } - /** * Get the instance of MixpanelAPI associated with your Mixpanel project token. * @@ -446,6 +473,50 @@ public static MixpanelAPI getInstance(Context context, String token, JSONObject * @return an instance of MixpanelAPI associated with your project */ public static MixpanelAPI getInstance(Context context, String token, boolean optOutTrackingDefault, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { + MixpanelOptions options = new MixpanelOptions.Builder() + .instanceName(instanceName) + .optOutTrackingDefault(optOutTrackingDefault) + .superProperties(superProperties) + .build(); + return getInstance(context, token, trackAutomaticEvents, options); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token + * and configured with the provided options. + * + *

Use getInstance to get a reference to a shared + * instance of MixpanelAPI you can use to send events + * and People Analytics updates to Mixpanel. This overload allows for more + * detailed configuration via the {@link MixpanelOptions} parameter.

+ *

getInstance is thread safe, but the returned instance is not, + * and may be shared with other callers of getInstance. + * The best practice is to call getInstance, and use the returned MixpanelAPI, + * object from a single thread (probably the main UI thread of your application).

+ *

If you do choose to track events from multiple threads in your application, + * you should synchronize your calls on the instance itself, like so:

+ *
+     * {@code
+     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token, true, options);
+     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+     * instance.track(...)
+     * }
+     * }
+     * 
+ * + * @param context The application context you are tracking. + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, + * in the settings dialog. + * @param trackAutomaticEvents Whether or not to collect common mobile events + * such as app sessions, first app opens, app updates, etc. + * @param options An instance of {@link MixpanelOptions} to configure the MixpanelAPI instance. + * This allows setting options like {@code optOutTrackingDefault}, + * {@code superProperties}, and {@code instanceName}. Other options within + * MixpanelOptions may be used by other SDK features if applicable. + * @return an instance of MixpanelAPI associated with your project and configured + * with the specified options. + */ + public static MixpanelAPI getInstance(Context context, String token, boolean trackAutomaticEvents, MixpanelOptions options) { if (null == token || null == context) { return null; } @@ -455,7 +526,7 @@ public static MixpanelAPI getInstance(Context context, String token, boolean opt if (null == sReferrerPrefs) { sReferrerPrefs = sPrefsLoader.loadPreferences(context, MPConfig.REFERRER_PREFS_NAME, null); } - String instanceKey = instanceName != null ? instanceName : token; + String instanceKey = options.getInstanceName() != null ? options.getInstanceName() : token; Map instances = sInstanceMap.get(instanceKey); if (null == instances) { instances = new HashMap(); @@ -464,7 +535,7 @@ public static MixpanelAPI getInstance(Context context, String token, boolean opt MixpanelAPI instance = instances.get(appContext); if (null == instance && ConfigurationChecker.checkBasicConfiguration(appContext)) { - instance = new MixpanelAPI(appContext, sReferrerPrefs, token, optOutTrackingDefault, superProperties, instanceName, trackAutomaticEvents); + instance = new MixpanelAPI(appContext, sReferrerPrefs, token, MPConfig.getInstance(context, options.getInstanceName()), options, trackAutomaticEvents); registerAppLinksListeners(context, instance); instances.put(appContext, instance); } @@ -669,6 +740,7 @@ public void identify(String distinctId, boolean usePeople) { mPersistentIdentity.setEventsDistinctId(distinctId); mPersistentIdentity.setAnonymousIdIfAbsent(currentEventsDistinctId); mPersistentIdentity.markEventsUserIdPresent(); + mFeatureFlagManager.loadFlags(); try { JSONObject identifyPayload = new JSONObject(); identifyPayload.put("$anon_distinct_id", currentEventsDistinctId); @@ -890,6 +962,24 @@ protected String getUserId() { return mPersistentIdentity.getEventsUserId(); } + /** + * Retrieves the Mixpanel project token. + * + * @return The Mixpanel project token currently being used. + */ + public String getToken() { + return mToken; + } + + /** + * Retrieves the Mixpanel configuration object. + * + * @return The current {@link MPConfig} object containing Mixpanel settings. + */ + public MPConfig getMPConfig() { + return mConfig; + } + /** * Register properties that will be sent with every subsequent call to {@link #track(String, JSONObject)}. * @@ -1181,6 +1271,17 @@ private String makeMapKey(String groupKey, Object groupID) { return groupKey + '_' + groupID; } + /** + * Returns a {@link Flags} object that can be used to retrieve and manage + * feature flags from Mixpanel. + * + * @return an instance of {@link Flags} that allows you to access feature flag + * configurations for your project. + */ + public Flags getFlags() { + return mFeatureFlagManager; + } + /** * Clears tweaks and all distinct_ids, superProperties, and push registrations from persistent storage. * Will not clear referrer information. @@ -1658,6 +1759,225 @@ public interface Group { void deleteGroup(); } + + /** + * Core interface for using Mixpanel Feature Flags. + * You can get an instance by calling {@link MixpanelAPI#getFlags()} (assuming such a method exists). + * + *

The Flags interface allows you to manage and retrieve feature flags defined in your Mixpanel project. + * Feature flags can be used to remotely configure your application's behavior, roll out new features + * gradually, or run A/B tests. + * + *

It's recommended to load flags early in your application's lifecycle, for example, + * in your main Application class or main Activity's {@code onCreate} method. + * + *

A typical use case for the Flags interface might look like this: + * + *

+     * {@code
+     *
+     * public class MainActivity extends Activity {
+     * MixpanelAPI mMixpanel;
+     * Flags mFlags;
+     *
+     * public void onCreate(Bundle saved) {
+     * super.onCreate(saved);
+     * MixanelOptions mpOptions = new MixpanelOptions.Builder().featureFlagsEnabled(true).build();
+     * mMixpanel = MixpanelAPI.getInstance(this, "YOUR MIXPANEL TOKEN", true, mpOptions);
+     * mFlags = mMixpanel.getFlags();
+     *
+     * // Asynchronously load flags
+     * mFlags.loadFlags();
+     *
+     * // Example of checking a flag asynchronously
+     * mFlags.isFlagEnabled("new-checkout-flow", false, isEnabled -> {
+     * if (isEnabled) {
+     * // Show new checkout flow
+     * } else {
+     * // Show old checkout flow
+     * }
+     * });
+     *
+     * // Example of getting a flag value synchronously after ensuring flags are ready
+     * if (mFlags.areFlagsReady()) {
+     * String buttonLabel = (String) mFlags.getVariantValueSync("home-button-label", "Default Label");
+     * // Use buttonLabel
+     * }
+     * }
+     * }
+     *
+     * }
+     * 
+ * + * @see MixpanelAPI + */ + public interface Flags { + + // --- Public Methods --- + + /** + * Asynchronously loads flags from the Mixpanel server if they haven't been loaded yet + * or if the cached flags have expired. This method will initiate a network request + * if necessary. Subsequent calls to get flag values (especially asynchronous ones) + * may trigger this load if flags are not yet available. + */ + void loadFlags(); + + /** + * Returns true if flags have been successfully loaded from the server and are + * currently available for synchronous access. This is useful to check before + * calling synchronous flag retrieval methods like {@link #getVariantSync(String, MixpanelFlagVariant)} + * to avoid them returning the fallback value immediately. + * + * @return true if flags are loaded and ready, false otherwise. + */ + boolean areFlagsReady(); + + // --- Sync Flag Retrieval --- + + /** + * Gets the complete feature flag data (key and value) synchronously. + * + *

IMPORTANT: This method can block the calling thread if it needs to wait for + * flags to be loaded (though the provided implementation detail suggests it returns + * fallback immediately if not ready). It is strongly recommended NOT to call this + * from the main UI thread if {@link #areFlagsReady()} is false, as it could lead + * to ANR (Application Not Responding) issues if blocking were to occur. + * + *

If flags are not ready (i.e., {@link #areFlagsReady()} returns false), this method + * will return the provided {@code fallback} value immediately without attempting to + * fetch flags or block. + * + * @param featureName The unique name (key) of the feature flag to retrieve. + * @param fallback The {@link MixpanelFlagVariant} instance to return if the specified + * flag is not found in the loaded set, or if flags are not ready. + * This must not be null. + * @return The {@link MixpanelFlagVariant} for the found feature flag, or the {@code fallback} + * if the flag is not found or flags are not ready. + */ + @NonNull + MixpanelFlagVariant getVariantSync(@NonNull String featureName, @NonNull MixpanelFlagVariant fallback); + + /** + * Gets the value of a specific feature flag synchronously. + * + *

IMPORTANT: Similar to {@link #getVariantSync(String, MixpanelFlagVariant)}, this method + * may involve blocking behavior if flags are being loaded. It's advised to check + * {@link #areFlagsReady()} first and avoid calling this on the main UI thread if flags + * might not be ready. + * + *

If flags are not ready, or if the specified {@code featureName} is not found, + * this method returns the {@code fallbackValue} immediately. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default value to return if the flag is not found, + * its value is null, or if flags are not ready. Can be null. + * @return The value of the feature flag (which could be a String, Boolean, Number, etc.), + * or the {@code fallbackValue}. + */ + @Nullable + Object getVariantValueSync(@NonNull String featureName, @Nullable Object fallbackValue); + + /** + * Checks if a specific feature flag is enabled synchronously. A flag is considered + * enabled if its value evaluates to {@code true}. + * + *

    + *
  • If the flag's value is a Boolean, it's returned directly.
  • + *
  • If the flag's value is a String, it's considered {@code true} if it equals (case-insensitive) "true" or "1".
  • + *
  • If the flag's value is a Number, it's considered {@code true} if it's non-zero.
  • + *
  • For other types, or if the flag is not found, it relies on the {@code fallbackValue}.
  • + *
+ * + *

IMPORTANT: See warnings on {@link #getVariantSync(String, MixpanelFlagVariant)} regarding + * potential blocking and the recommendation to check {@link #areFlagsReady()} first, + * especially when calling from the main UI thread. + * + *

Returns {@code fallbackValue} immediately if flags are not ready or the flag is not found. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default boolean value to return if the flag is not found, + * cannot be evaluated as a boolean, or if flags are not ready. + * @return {@code true} if the flag is present and evaluates to true, otherwise {@code false} + * (or the {@code fallbackValue}). + */ + boolean isEnabledSync(@NonNull String featureName, boolean fallbackValue); + + // --- Async Flag Retrieval --- + + /** + * Asynchronously gets the complete feature flag data (key and value). + * + *

If flags are not currently loaded, this method will trigger a fetch from the + * Mixpanel server. The provided {@code completion} callback will be invoked on the + * main UI thread once the operation is complete. + * + *

If the fetch fails or the specific flag is not found after a successful fetch, + * the {@code fallback} data will be provided to the completion callback. + * + * @param featureName The unique name (key) of the feature flag to retrieve. + * @param fallback The {@link MixpanelFlagVariant} instance to return via the callback + * if the flag is not found or if the fetch operation fails. + * This must not be null. + * @param completion The {@link FlagCompletionCallback} that will be invoked on the main + * thread with the result (either the found {@link MixpanelFlagVariant} or + * the {@code fallback}). This must not be null. + */ + void getVariant( + @NonNull String featureName, + @NonNull MixpanelFlagVariant fallback, + @NonNull FlagCompletionCallback completion + ); + + /** + * Asynchronously gets the value of a specific feature flag. + * + *

If flags are not currently loaded, this method will trigger a fetch. The + * {@code completion} callback is invoked on the main UI thread with the flag's + * value or the {@code fallbackValue}. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default value to return via the callback if the flag is + * not found, its value is null, or if the fetch operation fails. + * Can be null. + * @param completion The {@link FlagCompletionCallback} that will be invoked on the main + * thread with the result (the flag's value or the {@code fallbackValue}). + * This must not be null. + */ + void getVariantValue( + @NonNull String featureName, + @Nullable Object fallbackValue, + @NonNull FlagCompletionCallback completion + ); + + + /** + * Asynchronously checks if a specific feature flag is enabled. The evaluation of + * "enabled" follows the same rules as {@link #isEnabledSync(String, boolean)}. + * + *

If flags are not currently loaded, this method will trigger a fetch. The + * {@code completion} callback is invoked on the main UI thread with the boolean result. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default boolean value to return via the callback if the flag + * is not found, cannot be evaluated as a boolean, or if the + * fetch operation fails. + * @param completion The {@link FlagCompletionCallback} that will be invoked on the main + * thread with the boolean result. This must not be null. + */ + void isEnabled( + @NonNull String featureName, + boolean fallbackValue, + @NonNull FlagCompletionCallback completion + ); + } + + + + + + + /** * Attempt to register MixpanelActivityLifecycleCallbacks to the application's event lifecycle. * Once registered, we can automatically flush on an app background. @@ -2339,6 +2659,13 @@ private static void checkIntentForInboundAppLink(Context context) { return mContext; } + RemoteService getHttpService() { + if (this.mHttpService == null) { + this.mHttpService = new HttpService(false, null); + } + return this.mHttpService; + } + private final Context mContext; private final AnalyticsMessages mMessages; private final MPConfig mConfig; @@ -2352,6 +2679,8 @@ private static void checkIntentForInboundAppLink(Context context) { private final Map mEventTimings; private MixpanelActivityLifecycleCallbacks mMixpanelActivityLifecycleCallbacks; private final SessionMetadata mSessionMetadata; + private FeatureFlagManager mFeatureFlagManager; + private RemoteService mHttpService; // Maps each token to a singleton MixpanelAPI instance private static final Map> sInstanceMap = new HashMap>(); diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java new file mode 100644 index 00000000..cc84b6f6 --- /dev/null +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java @@ -0,0 +1,76 @@ +package com.mixpanel.android.mpmetrics; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Represents the data associated with a feature flag variant from the Mixpanel API. + * This class stores the key and value of a specific variant for a feature flag. + * It can be instantiated either by parsing an API response or by creating a fallback instance. + */ +public class MixpanelFlagVariant { + + /** + * The key of the feature flag variant. This corresponds to the 'variant_key' + * field in the Mixpanel API response. It cannot be null. + */ + @NonNull + public final String key; + + /** + * The value of the feature flag variant. This corresponds to the 'variant_value' + * field in the Mixpanel API response. The value can be of type Boolean, String, + * Number (Integer, Double, Float, Long), JSONArray, JSONObject, or it can be null. + */ + @Nullable + public final Object value; + + /** + * Constructs a {@code FeatureFlagData} object when parsing an API response. + * + * @param key The key of the feature flag variant. Corresponds to 'variant_key' from the API. Cannot be null. + * @param value The value of the feature flag variant. Corresponds to 'variant_value' from the API. + * Can be Boolean, String, Number, JSONArray, JSONObject, or null. + */ + public MixpanelFlagVariant(@NonNull String key, @Nullable Object value) { + this.key = key; + this.value = value; + } + + /** + * Constructs a {@code FeatureFlagData} object for creating fallback instances. + * In this case, the provided {@code keyAndValue} is used as both the key and the value + * for the feature flag data. This is typically used when a flag is not found + * and a default string value needs to be returned. + * + * @param keyAndValue The string value to be used as both the key and the value for this fallback. Cannot be null. + */ + public MixpanelFlagVariant(@NonNull String keyAndValue) { + this.key = keyAndValue; // Default key to the value itself + this.value = keyAndValue; + } + + /** + * Constructs a {@code FeatureFlagData} object for creating fallback instances. + * In this version, the key is set to an empty string (""), and the provided {@code value} + * is used as the value for the feature flag data. This is typically used when a + * flag is not found or an error occurs, and a default value needs to be provided. + * + * @param value The object value to be used for this fallback. Cannot be null. + * This can be of type Boolean, String, Number, JSONArray, or JSONObject. + */ + public MixpanelFlagVariant(@NonNull Object value) { + this.key = ""; + this.value = value; + } + + /** + * Default constructor that initializes an empty {@code FeatureFlagData} object. + * The key is set to an empty string ("") and the value is set to null. + * This constructor might be used internally or for specific default cases. + */ + MixpanelFlagVariant() { + this.key = ""; + this.value = null; + } +} \ No newline at end of file diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java new file mode 100644 index 00000000..637ee916 --- /dev/null +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java @@ -0,0 +1,165 @@ +package com.mixpanel.android.mpmetrics; + +import static com.mixpanel.android.mpmetrics.ConfigurationChecker.LOGTAG; + +import com.mixpanel.android.util.MPLog; + +import org.json.JSONObject; + +public class MixpanelOptions { + + private final String instanceName; + private final boolean optOutTrackingDefault; + private final JSONObject superProperties; + private final boolean featureFlagsEnabled; + private final JSONObject featureFlagsContext; + + private MixpanelOptions(Builder builder) { + this.instanceName = builder.instanceName; + this.optOutTrackingDefault = builder.optOutTrackingDefault; + this.superProperties = builder.superProperties; + this.featureFlagsEnabled = builder.featureFlagsEnabled; + this.featureFlagsContext = builder.featureFlagsContext; + } + + public String getInstanceName() { + return instanceName; + } + + public boolean isOptOutTrackingDefault() { + return optOutTrackingDefault; + } + + public JSONObject getSuperProperties() { + // Defensive copy to prevent modification of the internal JSONObject + if (superProperties == null) { + return null; + } + try { + return new JSONObject(superProperties.toString()); + } catch (Exception e) { + // This should ideally not happen if superProperties was a valid JSONObject + MPLog.e(LOGTAG, "Invalid super properties", e); + return null; + } + } + + public boolean areFeatureFlagsEnabled() { + return featureFlagsEnabled; + } + + public JSONObject getFeatureFlagsContext() { + // Defensive copy + if (featureFlagsContext == null) { + return new JSONObject(); + } + try { + return new JSONObject(featureFlagsContext.toString()); + } catch (Exception e) { + // This should ideally not happen if featureFlagsContext was a valid JSONObject + MPLog.e(LOGTAG, "Invalid feature flags context", e); + return new JSONObject(); + } + } + + public static class Builder { + private String instanceName; + private boolean optOutTrackingDefault = false; + private JSONObject superProperties; + private boolean featureFlagsEnabled = false; + private JSONObject featureFlagsContext = new JSONObject(); + + public Builder() { + } + + /** + * Sets the distinct instance name for the MixpanelAPI. This is useful if you want to + * manage multiple Mixpanel project instances. + * + * @param instanceName The unique name for the Mixpanel instance. + * @return This Builder instance for chaining. + */ + public Builder instanceName(String instanceName) { + this.instanceName = instanceName; + return this; + } + + /** + * Sets the default opt-out tracking state. If true, the SDK will not send any + * events or profile updates by default. This can be overridden at runtime. + * + * @param optOutTrackingDefault True to opt-out of tracking by default, false otherwise. + * @return This Builder instance for chaining. + */ + public Builder optOutTrackingDefault(boolean optOutTrackingDefault) { + this.optOutTrackingDefault = optOutTrackingDefault; + return this; + } + + /** + * Sets the super properties to be sent with every event. + * These properties are persistently stored. + * + * @param superProperties A JSONObject containing key-value pairs for super properties. + * The provided JSONObject will be defensively copied. + * @return This Builder instance for chaining. + */ + public Builder superProperties(JSONObject superProperties) { + if (superProperties == null) { + this.superProperties = null; + } else { + try { + // Defensive copy + this.superProperties = new JSONObject(superProperties.toString()); + } catch (Exception e) { + // Log error or handle as appropriate if JSON is invalid + this.superProperties = null; + } + } + return this; + } + + /** + * Enables or disables the Mixpanel feature flags functionality. + * + * @param featureFlagsEnabled True to enable feature flags, false to disable. + * @return This Builder instance for chaining. + */ + public Builder featureFlagsEnabled(boolean featureFlagsEnabled) { + this.featureFlagsEnabled = featureFlagsEnabled; + return this; + } + + /** + * Sets the context to be used for evaluating feature flags. + * This can include properties like distinct_id or other custom properties. + * + * @param featureFlagsContext A JSONObject containing key-value pairs for the feature flags context. + * The provided JSONObject will be defensively copied. + * @return This Builder instance for chaining. + */ + public Builder featureFlagsContext(JSONObject featureFlagsContext) { + if (featureFlagsContext == null) { + this.featureFlagsContext = new JSONObject(); + } else { + try { + // Defensive copy + this.featureFlagsContext = new JSONObject(featureFlagsContext.toString()); + } catch (Exception e) { + // Log error or handle as appropriate if JSON is invalid + this.featureFlagsContext = null; + } + } + return this; + } + + /** + * Builds and returns a {@link MixpanelOptions} instance with the configured settings. + * + * @return A new {@link MixpanelOptions} instance. + */ + public MixpanelOptions build() { + return new MixpanelOptions(this); + } + } +} diff --git a/src/main/java/com/mixpanel/android/util/HttpService.java b/src/main/java/com/mixpanel/android/util/HttpService.java index 383b23e1..daf8954b 100644 --- a/src/main/java/com/mixpanel/android/util/HttpService.java +++ b/src/main/java/com/mixpanel/android/util/HttpService.java @@ -8,6 +8,9 @@ import android.net.NetworkInfo; import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; @@ -108,123 +111,198 @@ private boolean onOfflineMode(OfflineMode offlineMode) { return onOfflineMode; } + /** + * Performs an HTTP POST request. Handles either URL-encoded parameters OR a raw byte request body. + * Includes support for custom headers and network error listening. + */ @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) throws ServiceUnavailableException, IOException { - MPLog.v(LOGTAG, "Attempting request to " + endpointUrl); - + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Use if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory + ) throws ServiceUnavailableException, IOException { + + MPLog.v(LOGTAG, "Attempting request to " + endpointUrl + (requestBodyBytes == null ? " (URL params)" : " (Raw Body)")); byte[] response = null; - - // the while(retries) loop is a workaround for a bug in some Android HttpURLConnection - // libraries- The underlying library will attempt to reuse stale connections, - // meaning the second (or every other) attempt to connect fails with an EOFException. - // Apparently this nasty retry logic is the current state of the workaround art. int retries = 0; boolean succeeded = false; + while (retries < 3 && !succeeded) { InputStream in = null; - OutputStream out = null; + OutputStream out = null; // Raw output stream HttpURLConnection connection = null; + // Variables for error listener reporting String targetIpAddress = null; long startTimeNanos = System.nanoTime(); long uncompressedBodySize = -1; - long compressedBodySize = -1; + long compressedBodySize = -1; // Only set if gzip applied to params try { + // --- Connection Setup --- final URL url = new URL(endpointUrl); + try { // Get IP Address for error reporting, but don't fail request if DNS fails here + InetAddress inetAddress = InetAddress.getByName(url.getHost()); + targetIpAddress = inetAddress.getHostAddress(); + } catch (Exception e) { + MPLog.v(LOGTAG, "Could not resolve IP address for " + url.getHost(), e); + targetIpAddress = "N/A"; // Default if lookup fails + } - InetAddress inetAddress = InetAddress.getByName(url.getHost()); - targetIpAddress = inetAddress.getHostAddress(); connection = (HttpURLConnection) url.openConnection(); if (null != socketFactory && connection instanceof HttpsURLConnection) { ((HttpsURLConnection) connection).setSSLSocketFactory(socketFactory); } - - if (interactor != null && isProxyRequest(endpointUrl)) { - Map headers = interactor.getProxyRequestHeaders(); - if (headers != null) { - for (Map.Entry entry : headers.entrySet()) { - connection.setRequestProperty(entry.getKey(), entry.getValue()); + connection.setConnectTimeout(2000); + connection.setReadTimeout(30000); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + + // --- Default Content-Type (can be overridden by headers map) --- + String contentType = (requestBodyBytes != null) + ? "application/json; charset=utf-8" // Default for raw body + : "application/x-www-form-urlencoded; charset=utf-8"; // Default for params + + // --- Apply Custom Headers (and determine final Content-Type) --- + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + if (entry.getKey().equalsIgnoreCase("Content-Type")) { + contentType = entry.getValue(); // Use explicit content type } } } + connection.setRequestProperty("Content-Type", contentType); - connection.setConnectTimeout(2000); - connection.setReadTimeout(30000); + // Apply proxy headers AFTER custom headers + if (interactor != null && isProxyRequest(endpointUrl)) { /* ... Apply proxy headers ... */ + Map proxyHeaders = interactor.getProxyRequestHeaders(); + if (proxyHeaders != null) { + for (Map.Entry entry : proxyHeaders.entrySet()) { connection.setRequestProperty(entry.getKey(), entry.getValue()); } + } + } - byte[] bodyBytesToSend = null; - if (null != params) { + // --- Prepare and Write Body --- + byte[] bytesToWrite; + if (requestBodyBytes != null) { + // --- Use Raw Body --- + bytesToWrite = requestBodyBytes; + uncompressedBodySize = bytesToWrite.length; + connection.setFixedLengthStreamingMode(uncompressedBodySize); + MPLog.v(LOGTAG, "Sending raw body of size: " + uncompressedBodySize); + } else if (params != null) { + // --- Use URL Encoded Params --- Uri.Builder builder = new Uri.Builder(); for (Map.Entry param : params.entrySet()) { builder.appendQueryParameter(param.getKey(), param.getValue().toString()); } String query = builder.build().getEncodedQuery(); - byte[] originalBodyBytes = Objects.requireNonNull(query).getBytes(StandardCharsets.UTF_8); - uncompressedBodySize = originalBodyBytes.length; + byte[] queryBytes = Objects.requireNonNull(query).getBytes(StandardCharsets.UTF_8); + uncompressedBodySize = queryBytes.length; + MPLog.v(LOGTAG, "Sending URL params (raw size): " + uncompressedBodySize); if (shouldGzipRequestPayload) { + // Apply GZIP specifically to the URL-encoded params ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) { - gzipOut.write(originalBodyBytes); - } - bodyBytesToSend = baos.toByteArray(); - compressedBodySize = bodyBytesToSend.length; + gzipOut.write(queryBytes); + } // try-with-resources ensures close + bytesToWrite = baos.toByteArray(); + compressedBodySize = bytesToWrite.length; connection.setRequestProperty(CONTENT_ENCODING_HEADER, GZIP_CONTENT_TYPE_HEADER); connection.setFixedLengthStreamingMode(compressedBodySize); + MPLog.v(LOGTAG, "Gzipping params, compressed size: " + compressedBodySize); } else { - bodyBytesToSend = originalBodyBytes; + bytesToWrite = queryBytes; connection.setFixedLengthStreamingMode(uncompressedBodySize); } - connection.setDoOutput(true); - connection.setRequestMethod("POST"); - out = connection.getOutputStream(); - out.write(bodyBytesToSend); - out.flush(); - out.close(); - out = null; + } else { + // No body and no params + bytesToWrite = new byte[0]; + uncompressedBodySize = 0; + connection.setFixedLengthStreamingMode(0); + MPLog.v(LOGTAG, "Sending POST request with empty body."); } + + // Write the prepared bytes + out = new BufferedOutputStream(connection.getOutputStream()); + out.write(bytesToWrite); + out.flush(); + out.close(); // Close output stream before getting response + out = null; + + // --- Process Response --- + int responseCode = connection.getResponseCode(); + String responseMessage = connection.getResponseMessage(); // Get message for logging/errors + MPLog.v(LOGTAG, "Response Code: " + responseCode); if (interactor != null && isProxyRequest(endpointUrl)) { - interactor.onProxyResponse(endpointUrl, connection.getResponseCode()); + interactor.onProxyResponse(endpointUrl, responseCode); + } + + if (responseCode >= 200 && responseCode < 300) { // Success + in = connection.getInputStream(); + response = slurp(in); + succeeded = true; + } else if (responseCode >= MIN_UNAVAILABLE_HTTP_RESPONSE_CODE && responseCode <= MAX_UNAVAILABLE_HTTP_RESPONSE_CODE) { // Server Error 5xx + // Report error via listener before throwing + onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, + new ServiceUnavailableException("Service Unavailable: " + responseCode, connection.getHeaderField("Retry-After"))); + // Now throw the exception + throw new ServiceUnavailableException("Service Unavailable: " + responseCode, connection.getHeaderField("Retry-After")); + } else { // Other errors (4xx etc.) + MPLog.w(LOGTAG, "HTTP error " + responseCode + " (" + responseMessage + ") for URL: " + endpointUrl); + String errorBody = null; + try { in = connection.getErrorStream(); if (in != null) { byte[] errorBytes = slurp(in); errorBody = new String(errorBytes, StandardCharsets.UTF_8); MPLog.w(LOGTAG, "Error Body: " + errorBody); } + } catch (Exception e) { MPLog.w(LOGTAG, "Could not read error stream.", e); } + // Report error via listener + onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, + new IOException("HTTP error response: " + responseCode + " " + responseMessage + (errorBody != null ? " - Body: " + errorBody : ""))); + response = null; // Indicate failure with null response + succeeded = true; // Mark as succeeded to stop retry loop for definitive HTTP errors } - in = connection.getInputStream(); - response = slurp(in); - in.close(); - in = null; - succeeded = true; + } catch (final EOFException e) { + // Report error BEFORE retry attempt onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e); - MPLog.d(LOGTAG, "Failure to connect, likely caused by a known issue with Android lib. Retrying."); - retries = retries + 1; - } catch (final IOException e) { - onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e); - if (connection != null && connection.getResponseCode() >= MIN_UNAVAILABLE_HTTP_RESPONSE_CODE && connection.getResponseCode() <= MAX_UNAVAILABLE_HTTP_RESPONSE_CODE) { - throw new ServiceUnavailableException("Service Unavailable", connection.getHeaderField("Retry-After")); - } else { - throw e; - } - } catch (final Exception e) { + MPLog.d(LOGTAG, "EOFException, likely network issue. Retrying request to " + endpointUrl); + retries++; + } catch (final IOException e) { // Includes ServiceUnavailableException if thrown above + // Report error via listener onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e); + // Re-throw the original exception throw e; + } catch (final Exception e) { // Catch any other unexpected exceptions + // Report error via listener + onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e); + // Wrap and re-throw as IOException? Or handle differently? + // Let's wrap in IOException for consistency with method signature. + throw new IOException("Unexpected exception during network request", e); + } finally { + // Clean up resources + if (null != out) try { out.close(); } catch (final IOException e) { /* ignore */ } + if (null != in) try { in.close(); } catch (final IOException e) { /* ignore */ } + if (null != connection) connection.disconnect(); } - finally { - if (null != out) - try { out.close(); } catch (final IOException e) {} - if (null != in) - try { in.close(); } catch (final IOException e) {} - if (null != connection) - connection.disconnect(); - } - } - if (retries >= 3) { - MPLog.v(LOGTAG, "Could not connect to Mixpanel service after three retries."); + } // End while loop + + if (!succeeded) { + MPLog.e(LOGTAG, "Could not complete request to " + endpointUrl + " after " + retries + " retries."); + // Optionally report final failure via listener here if desired, though individual errors were already reported + throw new IOException("Request failed after multiple retries."); // Indicate final failure } - return response; + + return response; // Can be null if a non-retriable HTTP error occurred } + private void onNetworkError(HttpURLConnection connection, String endpointUrl, String targetIpAddress, long startTimeNanos, long uncompressedBodySize, long compressedBodySize, Exception e) { if (this.networkErrorListener != null) { long endTimeNanos = System.nanoTime(); - long durationMillis = TimeUnit.NANOSECONDS.toMillis(endTimeNanos - startTimeNanos); + long durationNanos = Math.max(0, endTimeNanos - startTimeNanos); + long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); int responseCode = -1; String responseMessage = ""; if (connection != null) { @@ -236,7 +314,13 @@ private void onNetworkError(HttpURLConnection connection, String endpointUrl, St } } String ip = (targetIpAddress == null) ? "N/A" : targetIpAddress; - this.networkErrorListener.onNetworkError(endpointUrl, ip, durationMillis, uncompressedBodySize, compressedBodySize, responseCode, responseMessage, e); + long finalUncompressedSize = Math.max(-1, uncompressedBodySize); + long finalCompressedSize = Math.max(-1, compressedBodySize); + try { + this.networkErrorListener.onNetworkError(endpointUrl, ip, durationMillis, finalUncompressedSize, finalCompressedSize, responseCode, responseMessage, e); + } catch(Exception listenerException) { + MPLog.e(LOGTAG, "Network error listener threw an exception", listenerException); + } } } diff --git a/src/main/java/com/mixpanel/android/util/JsonUtils.java b/src/main/java/com/mixpanel/android/util/JsonUtils.java new file mode 100644 index 00000000..1ea09814 --- /dev/null +++ b/src/main/java/com/mixpanel/android/util/JsonUtils.java @@ -0,0 +1,170 @@ +package com.mixpanel.android.util; // Or com.mixpanel.android.mpmetrics if preferred + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.mixpanel.android.mpmetrics.MixpanelFlagVariant; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Utility class for JSON operations, particularly for handling arbitrary value types + * encountered in feature flags. + */ +public class JsonUtils { + + private static final String LOGTAG = "MixpanelAPI.JsonUtils"; // Re-use Mixpanel log tag convention + + /** + * Parses a JSON value obtained from org.json (like JSONObject.get() or JSONArray.get()) + * into a standard Java Object (String, Boolean, Number, {@code List}, {@code Map}, or null). + * Handles JSONObject.NULL correctly. + * + * @param jsonValue The object retrieved from org.json library. + * @return The corresponding standard Java object, or null if the input was JSONObject.NULL. + * @throws JSONException if the input is an unsupported type or if nested parsing fails. + */ + @Nullable + public static Object parseJsonValue(@Nullable Object jsonValue) throws JSONException { + if (jsonValue == null || jsonValue == JSONObject.NULL) { + return null; + } + + if (jsonValue instanceof Boolean || + jsonValue instanceof String || + jsonValue instanceof Integer || + jsonValue instanceof Long || + jsonValue instanceof Double || + jsonValue instanceof Float) { + // Primitives (including Numbers) are returned directly + return jsonValue; + } + // Handle numbers that might not be boxed primitives? (Shouldn't happen with org.json?) + if (jsonValue instanceof Number) { + return jsonValue; + } + + + if (jsonValue instanceof JSONObject) { + return jsonObjectToMap((JSONObject) jsonValue); + } + + if (jsonValue instanceof JSONArray) { + return jsonArrayToList((JSONArray) jsonValue); + } + + // If we got here, the type is unexpected + MPLog.w(LOGTAG, "Could not parse JSON value of type: " + jsonValue.getClass().getSimpleName()); + throw new JSONException("Unsupported JSON type encountered: " + jsonValue.getClass().getSimpleName()); + } + + /** + * Converts a JSONObject to a Map, recursively parsing nested values. + * + * @param jsonObject The JSONObject to convert. + * @return A Map representing the JSONObject. + * @throws JSONException if parsing fails. + */ + @NonNull + private static Map jsonObjectToMap(@NonNull JSONObject jsonObject) throws JSONException { + Map map = new HashMap<>(); + Iterator keys = jsonObject.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object value = jsonObject.get(key); + map.put(key, parseJsonValue(value)); // Recursively parse nested values + } + return map; + } + + /** + * Converts a JSONArray to a List, recursively parsing nested values. + * + * @param jsonArray The JSONArray to convert. + * @return A List representing the JSONArray. + * @throws JSONException if parsing fails. + */ + @NonNull + private static List jsonArrayToList(@NonNull JSONArray jsonArray) throws JSONException { + List list = new ArrayList<>(jsonArray.length()); + for (int i = 0; i < jsonArray.length(); i++) { + Object value = jsonArray.get(i); + list.add(parseJsonValue(value)); // Recursively parse nested values + } + return list; + } + + + /** + * Parses the "flags" object from a /flags API response JSONObject. + * + * @param responseJson The root JSONObject from the API response. + * @return A Map where keys are feature flag names (String) and values are FeatureFlagData objects. + * Returns an empty map if parsing fails or the "flags" key is missing/invalid. + */ + @NonNull + public static Map parseFlagsResponse(@Nullable JSONObject responseJson) { + Map flagsMap = new HashMap<>(); + if (responseJson == null) { + MPLog.e(LOGTAG, "Cannot parse null flags response"); + return flagsMap; + } + + JSONObject flagsObject = null; + try { + if (responseJson.has(MPConstants.Flags.FLAGS_KEY) && !responseJson.isNull(MPConstants.Flags.FLAGS_KEY)) { + flagsObject = responseJson.getJSONObject(MPConstants.Flags.FLAGS_KEY); + } else { + MPLog.w(LOGTAG, "Flags response JSON does not contain 'flags' key or it's null."); + return flagsMap; // No flags found + } + + Iterator keys = flagsObject.keys(); + while (keys.hasNext()) { + String featureName = keys.next(); + try { + if (flagsObject.isNull(featureName)) { + MPLog.w(LOGTAG, "Flag definition is null for key: " + featureName); + continue; // Skip null flag definitions + } + JSONObject flagDefinition = flagsObject.getJSONObject(featureName); + + String variantKey = null; + if (flagDefinition.has(MPConstants.Flags.VARIANT_KEY) && !flagDefinition.isNull(MPConstants.Flags.VARIANT_KEY)) { + variantKey = flagDefinition.getString(MPConstants.Flags.VARIANT_KEY); + } else { + MPLog.w(LOGTAG, "Flag definition missing 'variant_key' for key: " + featureName); + continue; // Skip flags without a variant key + } + + Object variantValue = null; + if (flagDefinition.has(MPConstants.Flags.VARIANT_VALUE)) { // Check presence before getting + Object rawValue = flagDefinition.get(MPConstants.Flags.VARIANT_VALUE); // Get raw value (could be JSONObject.NULL) + variantValue = parseJsonValue(rawValue); // Parse it properly + } else { + MPLog.w(LOGTAG, "Flag definition missing 'variant_value' for key: " + featureName + ". Assuming null value."); + } + + MixpanelFlagVariant flagData = new MixpanelFlagVariant(variantKey, variantValue); + flagsMap.put(featureName, flagData); + + } catch (JSONException e) { + MPLog.e(LOGTAG, "Error parsing individual flag definition for key: " + featureName, e); + // Continue parsing other flags + } + } + } catch (JSONException e) { + MPLog.e(LOGTAG, "Error parsing outer 'flags' object in response", e); + } + + return flagsMap; + } +} \ No newline at end of file diff --git a/src/main/java/com/mixpanel/android/util/MPConstants.java b/src/main/java/com/mixpanel/android/util/MPConstants.java index 6b0c66a5..dbe33646 100644 --- a/src/main/java/com/mixpanel/android/util/MPConstants.java +++ b/src/main/java/com/mixpanel/android/util/MPConstants.java @@ -15,5 +15,11 @@ public static class URL { public static final String EVENT = "/track/"; public static final String PEOPLE = "/engage/"; public static final String GROUPS = "/groups/"; + public static final String FLAGS = "/flags/"; + } + public static class Flags { + public static final String FLAGS_KEY = "flags"; + public static final String VARIANT_KEY = "variant_key"; + public static final String VARIANT_VALUE = "variant_value"; } } diff --git a/src/main/java/com/mixpanel/android/util/RemoteService.java b/src/main/java/com/mixpanel/android/util/RemoteService.java index 70ce8f4e..13758936 100644 --- a/src/main/java/com/mixpanel/android/util/RemoteService.java +++ b/src/main/java/com/mixpanel/android/util/RemoteService.java @@ -3,6 +3,9 @@ import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import java.io.IOException; import java.util.Map; @@ -14,7 +17,27 @@ public interface RemoteService { void checkIsMixpanelBlocked(); - byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, Map params, SSLSocketFactory socketFactory) + /** + * Performs an HTTP POST request. Handles either URL-encoded parameters OR a raw byte request body. + * + * @param endpointUrl The target URL. + * @param interactor Optional proxy interactor. + * @param params URL parameters to be URL-encoded and sent (used if requestBodyBytes is null). + * @param headers Optional map of custom headers (e.g., Authorization, Content-Type). + * @param requestBodyBytes Optional raw byte array for the request body. If non-null, this is sent directly, + * and the 'params' map is ignored for the body content. Ensure Content-Type header is set. + * @param socketFactory Optional custom SSLSocketFactory. + * @return The response body as a byte array, or null if the request failed with a non-retriable HTTP error code. + * @throws ServiceUnavailableException If the server returned a 5xx error with a Retry-After header. + * @throws IOException For network errors or non-5xx HTTP errors where reading failed. + */ + byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) throws ServiceUnavailableException, IOException; class ServiceUnavailableException extends Exception {