Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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);
}
}
}
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
33 changes: 27 additions & 6 deletions src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,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 +2037,31 @@ 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.
*/
private void checkFirstLaunchAsync() {
if (!mTrackAutomaticEvents) {
return;
}

new Thread(new Runnable() {
@Override
public void run() {
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);
}
}
}).start();
}

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