diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/StrictModeTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/StrictModeTest.java new file mode 100644 index 00000000..c74a4319 --- /dev/null +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/StrictModeTest.java @@ -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.") || + capturedOutput.contains("MPDbAdapter.") || + capturedOutput.contains("MixpanelAPI."); + + violationDetected[0] = hasKnownViolation; + } + + latch.countDown(); + } + }, 500); + } catch (Exception e) { + e.printStackTrace(); + latch.countDown(); + } + } + }); + + // Wait for test to complete + assertTrue("Test should complete within timeout", + 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.()\n" + + "- MixpanelAPI.()"); + } + + } 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)); + + 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); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java b/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java index a902d2b7..3b1e67de 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java @@ -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; @@ -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); @@ -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); } @@ -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"; diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java b/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java index 94a9f5ac..7d471bb8 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java @@ -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 @@ -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; } @@ -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; } @@ -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 */ diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java index 360febfd..a312dee4 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java @@ -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; @@ -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); } @@ -2041,6 +2038,52 @@ void isEnabled( @NonNull FlagCompletionCallback 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.