diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f36e7752c..1b1e1d171 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,16 +8,21 @@ on: jobs: build: - runs-on: macos-13 + runs-on: ubuntu-latest steps: - name: Checkout the code uses: actions/checkout@v4.1.7 + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - name: Run Unit test - uses: reactivecircus/android-emulator-runner@v2.32.0 + uses: reactivecircus/android-emulator-runner@v2.35.0 with: api-level: 34 - profile: Nexus 5X arch: x86_64 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim script: ./gradlew createDebugCoverageReport - name: Lint run: ./gradlew lint @@ -26,17 +31,17 @@ jobs: if: always() with: name: Unit Test Report - path: /Users/runner/work/mixpanel-android/mixpanel-android/build/reports/androidTests/connected + path: build/reports/androidTests/connected - name: Upload test coverage report uses: actions/upload-artifact@v4 with: name: Test Coverage Report - path: /Users/runner/work/mixpanel-android/mixpanel-android/build/reports/coverage/debug/ + path: build/reports/coverage/debug/ - name: Upload lint report uses: actions/upload-artifact@v4 with: name: Lint Report - path: /Users/runner/work/mixpanel-android/mixpanel-android/build/reports/lint-results.html + path: build/reports/lint-results.html - name: Android docs run: ./gradlew --info androidJavadocs diff --git a/src/androidTest/AndroidManifest.xml b/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..748fda232 --- /dev/null +++ b/src/androidTest/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java index 5c374909e..038915d45 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java @@ -17,6 +17,7 @@ import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.test.core.app.ActivityScenario; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; @@ -1358,8 +1359,8 @@ public void testIdentifyCall() throws JSONException { new ArrayList(); final AnalyticsMessages listener = new AnalyticsMessages( - InstrumentationRegistry.getInstrumentation().getContext(), - MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + InstrumentationRegistry.getInstrumentation().getTargetContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getTargetContext(), null)) { @Override public void eventsMessage(EventDescription heard) { if (!heard.isAutomatic()) { @@ -1371,9 +1372,10 @@ public void eventsMessage(EventDescription heard) { // Track calls to the flags endpoint final List flagsEndpointCalls = new ArrayList<>(); + // Create MixpanelAPI first to register lifecycle callbacks before launching activity MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI( - InstrumentationRegistry.getInstrumentation().getContext(), + InstrumentationRegistry.getInstrumentation().getTargetContext(), mMockPreferences, "Test Identify Call") { @Override @@ -1424,36 +1426,39 @@ public RemoteService.RequestResult performRequest( // Clear any flags calls from constructor flagsEndpointCalls.clear(); - // First identify should trigger loadFlags since distinctId changes - metrics.identify(newDistinctId); + // Launch an activity to bring the app to foreground, which triggers onForeground() + try (ActivityScenario scenario = ActivityScenario.launch(TestActivity.class)) { + // 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 - } + // 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); - // Second and third identify should NOT trigger loadFlags since distinctId doesn't change - metrics.identify(newDistinctId); - metrics.identify(newDistinctId); - - // Verify that only one $identify event was tracked - assertEquals(1, messages.size()); - AnalyticsMessages.EventDescription identifyEventDescription = messages.get(0); - assertEquals("$identify", identifyEventDescription.getEventName()); - String newDistinctIdIdentifyTrack = - identifyEventDescription.getProperties().getString("distinct_id"); - String anonDistinctIdIdentifyTrack = - identifyEventDescription.getProperties().getString("$anon_distinct_id"); - - 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); + // Verify that only one $identify event was tracked + assertEquals(1, messages.size()); + AnalyticsMessages.EventDescription identifyEventDescription = messages.get(0); + assertEquals("$identify", identifyEventDescription.getEventName()); + String newDistinctIdIdentifyTrack = + identifyEventDescription.getProperties().getString("distinct_id"); + String anonDistinctIdIdentifyTrack = + identifyEventDescription.getProperties().getString("$anon_distinct_id"); + + 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 diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/TestActivity.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/TestActivity.java new file mode 100644 index 000000000..9a559aca0 --- /dev/null +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/TestActivity.java @@ -0,0 +1,14 @@ +package com.mixpanel.android.mpmetrics; + +import android.app.Activity; +import android.os.Bundle; + +/** + * Simple test activity for instrumented tests that need an activity lifecycle. + */ +public class TestActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } +} diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java index 360febfd3..605726881 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java @@ -36,6 +36,7 @@ import java.util.Map.Entry; import java.util.TimeZone; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -222,8 +223,6 @@ public class MixpanelAPI implements FeatureFlagDelegate { getHttpService(), new FlagsConfig(options.areFeatureFlagsEnabled(), options.getFeatureFlagsContext())); - mFeatureFlagManager.loadFlags(); - if (options.isOptOutTrackingDefault() && (hasOptedOutTracking() || !mPersistentIdentity.hasOptOutFlag(token))) { optOutTracking(); @@ -825,7 +824,10 @@ public void identify(String distinctId, boolean usePeople) { mPersistentIdentity.setEventsDistinctId(distinctId); mPersistentIdentity.setAnonymousIdIfAbsent(currentEventsDistinctId); mPersistentIdentity.markEventsUserIdPresent(); - mFeatureFlagManager.loadFlags(); + // Ensure app has previously launched in foreground before network call. + if (mHasAppForegrounded.get()) { + mFeatureFlagManager.loadFlags(); + } try { JSONObject identifyPayload = new JSONObject(); identifyPayload.put("$anon_distinct_id", currentEventsDistinctId); @@ -2093,6 +2095,11 @@ public boolean isAppInForeground() { /* package */ void onForeground() { mSessionMetadata.initSession(); + // Ensure app has previously launched in foreground before network call. + mHasAppForegrounded.set(true); + if (mInitialFeatureFlagLoad.compareAndSet(false, true)) { + mFeatureFlagManager.loadFlags(); + } } // Package-level access. Used (at least) by MixpanelFCMMessagingService @@ -2798,6 +2805,10 @@ RemoteService getHttpService() { private final SessionMetadata mSessionMetadata; private FeatureFlagManager mFeatureFlagManager; private RemoteService mHttpService; + // Flag to track if app has entered foreground + private final AtomicBoolean mHasAppForegrounded = new AtomicBoolean(false); + // Flag to track if initial feature flags load has been initiated + private final AtomicBoolean mInitialFeatureFlagLoad = new AtomicBoolean(false); // Maps each token to a singleton MixpanelAPI instance private static final Map> sInstanceMap =