Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.mixpanel.android.mpmetrics;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.fail;

import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.StrictMode;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/**
* Test that MixpanelAPI initialization doesn't violate StrictMode policies
*/
@RunWith(AndroidJUnit4.class)
@LargeTest
public class StrictModeTest {

private Context mContext;
private static final String TEST_TOKEN = "test_token_strict_mode";

@Before
public void setUp() {
mContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
// Clear SharedPreferences for clean test
mContext.getSharedPreferences("com.mixpanel.android.mpmetrics.MixpanelAPI.SharedPreferencesLoader", Context.MODE_PRIVATE)
.edit()
.clear()
.apply();
}

@Test
public void testNoDiskReadViolationsOnMainThread() throws InterruptedException {
// This test verifies that initializing MixpanelAPI on the main thread
// doesn't cause StrictMode DiskReadViolations

// Capture the original error stream
final PrintStream originalErr = System.err;
final ByteArrayOutputStream errCapture = new ByteArrayOutputStream();
final PrintStream captureStream = new PrintStream(errCapture);

// Enable StrictMode to detect disk reads on main thread
StrictMode.ThreadPolicy originalPolicy = StrictMode.getThreadPolicy();

final CountDownLatch latch = new CountDownLatch(1);
final boolean[] violationDetected = {false};

try {
// Redirect System.err to capture StrictMode violations
System.setErr(captureStream);

// Set up StrictMode to detect disk reads
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.penaltyLog() // Log violations to System.err
.build());

// Run initialization on main thread
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
try {
// Initialize MixpanelAPI on main thread (this is what the user does)
MixpanelAPI mixpanel = MixpanelAPI.getInstance(mContext, TEST_TOKEN, true);
assertNotNull(mixpanel);

// Track an event to ensure more initialization happens
mixpanel.track("Test Event");

// Small delay to let async operations start
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
// Check captured output for violations
String capturedOutput = errCapture.toString();

// Check if any of the known violation points are in the output
if (capturedOutput.contains("DiskReadViolation") &&
capturedOutput.contains("com.mixpanel.android")) {

boolean hasKnownViolation =
capturedOutput.contains("PersistentIdentity.getTimeEvents") ||
capturedOutput.contains("MPDbAdapter$MPDatabaseHelper.<init>") ||
capturedOutput.contains("MPDbAdapter.<init>") ||
capturedOutput.contains("MixpanelAPI.<init>");

violationDetected[0] = hasKnownViolation;
}

latch.countDown();
}
}, 500);
} catch (Exception e) {
e.printStackTrace();
latch.countDown();
}
}
});

// Wait for test to complete
assertTrue("Test should complete within timeout",
Copy link

Copilot AI Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertTrue method is defined at the bottom of the test class but shadows the static Assert.assertTrue method. This creates confusion and could lead to incorrect test behavior. Use the imported Assert.assertTrue directly or rename the custom method to avoid shadowing.

Copilot uses AI. Check for mistakes.
latch.await(10, TimeUnit.SECONDS));

if (violationDetected[0]) {
fail("StrictMode DiskReadViolation detected in Mixpanel SDK initialization. " +
"Check the following locations:\n" +
"- PersistentIdentity.getTimeEvents()\n" +
"- MPDbAdapter$MPDatabaseHelper.<init>()\n" +
"- MixpanelAPI.<init>()");
}

} finally {
// Restore original StrictMode policy and error stream
StrictMode.setThreadPolicy(originalPolicy);
System.setErr(originalErr);
}
}

@Test
public void testInitializationCompletes() throws InterruptedException {
// Simple test to ensure the SDK can initialize without errors
final CountDownLatch latch = new CountDownLatch(1);
final MixpanelAPI[] mixpanelRef = new MixpanelAPI[1];
final Exception[] exceptionRef = new Exception[1];

new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
try {
// Initialize MixpanelAPI
mixpanelRef[0] = MixpanelAPI.getInstance(mContext, TEST_TOKEN, true);

// Track an event
mixpanelRef[0].track("Test Event");

// Access persistent identity (which would trigger getTimeEvents)
String distinctId = mixpanelRef[0].getDistinctId();
assertNotNull("Distinct ID should not be null", distinctId);

} catch (Exception e) {
exceptionRef[0] = e;
} finally {
latch.countDown();
}
}
});

assertTrue("Initialization should complete",
latch.await(5, TimeUnit.SECONDS));
Copy link

Copilot AI Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as line 111 - this assertTrue call uses the custom method instead of the imported Assert.assertTrue, which could lead to inconsistent test behavior.

Suggested change
latch.await(5, TimeUnit.SECONDS));
latch.await(5, TimeUnit.SECONDS));

Copilot uses AI. Check for mistakes.

if (exceptionRef[0] != null) {
fail("Exception during initialization: " + exceptionRef[0].getMessage());
}

assertNotNull("MixpanelAPI should be initialized", mixpanelRef[0]);
}

private void assertTrue(String message, boolean condition) {
if (!condition) {
fail(message);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ public void removeResidualImageFiles(File fileOrDirectory) {
mWorker.runMessage(m);
}

public void checkFirstLaunchMessage(final FirstLaunchDescription firstLaunchDescription) {
final Message m = Message.obtain();
m.what = CHECK_FIRST_LAUNCH;
m.obj = firstLaunchDescription;
mWorker.runMessage(m);
}

public void hardKill() {
final Message m = Message.obtain();
m.what = KILL_WORKER;
Expand Down Expand Up @@ -265,6 +272,19 @@ public String getDistinctId() {
private final String mDistinctId;
}

static class FirstLaunchDescription extends MixpanelDescription {
public FirstLaunchDescription(String token, MixpanelAPI mixpanelInstance) {
super(token);
this.mMixpanelInstance = mixpanelInstance;
}

public MixpanelAPI getMixpanelInstance() {
return mMixpanelInstance;
}

private final MixpanelAPI mMixpanelInstance;
}

static class MixpanelMessageDescription extends MixpanelDescription {
public MixpanelMessageDescription(String token, JSONObject message) {
super(token);
Expand Down Expand Up @@ -460,6 +480,20 @@ public void handleMessage(Message msg) {
} else if (msg.what == REMOVE_RESIDUAL_IMAGE_FILES) {
final File file = (File) msg.obj;
LegacyVersionUtils.removeLegacyResidualImageFiles(file);
} else if (msg.what == CHECK_FIRST_LAUNCH) {
final FirstLaunchDescription firstLaunchDescription = (FirstLaunchDescription) msg.obj;
final MixpanelAPI mixpanel = firstLaunchDescription.getMixpanelInstance();

// Check if this is the first launch (this does disk I/O, so it's safe on background thread)
try {
final boolean dbExists = mDbAdapter.getDatabaseFile().exists();
if (mixpanel.isFirstLaunch(dbExists)) {
mixpanel.track(AutomaticEvents.FIRST_OPEN, null, true);
mixpanel.setHasLaunched();
}
} catch (Exception e) {
MPLog.e(LOGTAG, "Failed to check first launch", e);
}
} else {
MPLog.e(LOGTAG, "Unexpected message received by Mixpanel worker: " + msg);
}
Expand Down Expand Up @@ -746,6 +780,8 @@ public long getTrackEngageRetryAfter() {
8; // Update or add properties to existing queued events
private static final int REMOVE_RESIDUAL_IMAGE_FILES =
9; // Remove residual image files left from the legacy SDK versions
private static final int CHECK_FIRST_LAUNCH =
10; // Check if this is the first launch and track the event

private static final String LOGTAG = "MixpanelAPI.Messages";

Expand Down
27 changes: 20 additions & 7 deletions src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,28 @@ public String getName() {
private static class MPDatabaseHelper extends SQLiteOpenHelper {
MPDatabaseHelper(Context context, String dbName, MPConfig config) {
super(context, dbName, null, DATABASE_VERSION);
mDatabaseFile = context.getDatabasePath(dbName);
mDatabaseFile = null; // Defer initialization to avoid disk I/O on main thread
mDatabaseName = dbName;
mConfig = config;
mContext = context;
}

/**
* Lazily get the database file, initializing if needed
*/
private synchronized File getDatabaseFile() {
if (mDatabaseFile == null) {
mDatabaseFile = mContext.getDatabasePath(mDatabaseName);
}
return mDatabaseFile;
}

/**
* Completely deletes the DB file from the file system.
*/
public void deleteDatabase() {
close();
mDatabaseFile.delete();
getDatabaseFile().delete();
}

@Override
Expand Down Expand Up @@ -176,9 +187,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
}

public boolean aboveMemThreshold() {
if (mDatabaseFile.exists()) {
return mDatabaseFile.length() > Math.max(mDatabaseFile.getUsableSpace(), mConfig.getMinimumDatabaseLimit()) ||
mDatabaseFile.length() > mConfig.getMaximumDatabaseLimit();
File dbFile = getDatabaseFile();
if (dbFile.exists()) {
return dbFile.length() > Math.max(dbFile.getUsableSpace(), mConfig.getMinimumDatabaseLimit()) ||
dbFile.length() > mConfig.getMaximumDatabaseLimit();
}
return false;
}
Expand Down Expand Up @@ -279,7 +291,8 @@ public boolean accept(File dir, String name) {
}
}

private final File mDatabaseFile;
private File mDatabaseFile;
private final String mDatabaseName;
private final MPConfig mConfig;
private final Context mContext;
}
Expand Down Expand Up @@ -667,7 +680,7 @@ public String[] generateDataString(Table table, String token) {
}

public File getDatabaseFile() {
return mDb.mDatabaseFile;
return mDb.getDatabaseFile();
}

/* For testing use only, do not call from in production code */
Expand Down
55 changes: 49 additions & 6 deletions src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
Expand Down Expand Up @@ -233,15 +234,11 @@ public class MixpanelAPI implements FeatureFlagDelegate {
registerSuperProperties(options.getSuperProperties());
}

final boolean dbExists = MPDbAdapter.getInstance(mContext, mConfig).getDatabaseFile().exists();
// Check first launch asynchronously to avoid disk I/O on main thread
checkFirstLaunchAsync();

registerMixpanelActivityLifecycleCallbacks();

if (mPersistentIdentity.isFirstLaunch(dbExists, mToken) && mTrackAutomaticEvents) {
track(AutomaticEvents.FIRST_OPEN, null, true);
mPersistentIdentity.setHasLaunched(mToken);
}

if (sendAppOpen() && mTrackAutomaticEvents) {
track("$app_open", null);
}
Expand Down Expand Up @@ -2041,6 +2038,52 @@ void isEnabled(
@NonNull FlagCompletionCallback<Boolean> completion);
}

/**
* Check if this is the first launch and track the first open event asynchronously.
* This avoids disk I/O on the main thread which would trigger StrictMode violations.
* Uses the SDK's message passing pattern when on main thread, or executes synchronously
* when already on a background thread.
*/
private void checkFirstLaunchAsync() {
if (!mTrackAutomaticEvents) {
return;
}

// Only defer to async if we're on the main thread to avoid StrictMode violations
if (Looper.getMainLooper().getThread() == Thread.currentThread()) {
// Use message passing pattern to check first launch on background thread
mMessages.checkFirstLaunchMessage(new AnalyticsMessages.FirstLaunchDescription(mToken, this));
} else {
// We're already on a background thread, safe to check synchronously
// This ensures tests and background initializations work correctly
try {
final boolean dbExists = MPDbAdapter.getInstance(mContext, mConfig).getDatabaseFile().exists();
if (mPersistentIdentity.isFirstLaunch(dbExists, mToken)) {
track(AutomaticEvents.FIRST_OPEN, null, true);
mPersistentIdentity.setHasLaunched(mToken);
}
} catch (Exception e) {
MPLog.e(LOGTAG, "Failed to check first launch", e);
}
}
}

/**
* Package-private method for checking if this is the first launch.
* Called from the background thread via message passing.
*/
boolean isFirstLaunch(boolean dbExists) {
return mPersistentIdentity.isFirstLaunch(dbExists, mToken);
}

/**
* Package-private method for setting that the app has launched.
* Called from the background thread via message passing.
*/
void setHasLaunched() {
mPersistentIdentity.setHasLaunched(mToken);
}

/**
* Attempt to register MixpanelActivityLifecycleCallbacks to the application's event lifecycle.
* Once registered, we can automatically flush on an app background.
Expand Down
Loading