From bf056da2de641f583f35077206bf1a11c2a552f2 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 1 Sep 2026 16:33:13 -0400 Subject: [PATCH 1/2] fix(android): avoid legacy queue disk access during setup --- .changeset/calm-dingos-setup.md | 5 +++++ .../main/java/com/posthog/android/PostHogAndroid.kt | 5 ++++- .../java/com/posthog/android/PostHogAndroidTest.kt | 12 +++++++++--- .../src/test/java/com/posthog/android/Utils.kt | 8 +++++++- 4 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 .changeset/calm-dingos-setup.md diff --git a/.changeset/calm-dingos-setup.md b/.changeset/calm-dingos-setup.md new file mode 100644 index 000000000..407ab3a0b --- /dev/null +++ b/.changeset/calm-dingos-setup.md @@ -0,0 +1,5 @@ +--- +'posthog-android': patch +--- + +Avoid synchronous disk access while resolving the legacy event queue during SDK setup. diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index a21cbee89..f1ce24052 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -31,6 +31,9 @@ import com.posthog.internal.PostHogSessionManager import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.io.File +// Previously accessed via `Context.getDir`, which prefixes names with "app_". +private const val LEGACY_STORAGE_DIRECTORY = "app_app_posthog-disk-queue" + /** * Main entry point for the Android SDK. * @@ -114,7 +117,7 @@ public class PostHogAndroid private constructor() { config.networkStatus = PostHogAndroidNetworkStatus(context) } - val legacyPath = context.getDir("app_posthog-disk-queue", Context.MODE_PRIVATE) + val legacyPath = File(context.applicationInfo.dataDir, LEGACY_STORAGE_DIRECTORY) val path = File(context.cacheDir, "posthog-disk-queue") val replayPath = File(context.cacheDir, "posthog-disk-replay-queue") val logsPath = File(context.cacheDir, "posthog-disk-logs-queue") diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index 1329d838c..5b0bf0165 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -18,7 +18,11 @@ import com.posthog.internal.PostHogNetworkStatus import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import java.io.File import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -82,14 +86,16 @@ internal class PostHogAndroidTest { } @Test - fun `sets legacy storage path`() { + fun `sets legacy storage path without resolving the directory`() { val config = PostHogAndroidConfig(API_KEY) - mockContextAppStart(context, tmpDir) + val app = mockContextAppStart(context, tmpDir) PostHogAndroid.setup(context, config) - assertNotNull(config.legacyStoragePrefix) + verify(app, never()).getDir(any(), any()) + val expectedPath = File(app.applicationInfo.dataDir, "app_app_posthog-disk-queue") + assertEquals(expectedPath.absolutePath, config.legacyStoragePrefix) } @Test diff --git a/posthog-android/src/test/java/com/posthog/android/Utils.kt b/posthog-android/src/test/java/com/posthog/android/Utils.kt index 255c02da8..d05fcef7c 100644 --- a/posthog-android/src/test/java/com/posthog/android/Utils.kt +++ b/posthog-android/src/test/java/com/posthog/android/Utils.kt @@ -108,13 +108,19 @@ public fun Context.mockDisplayMetrics() { public fun mockContextAppStart( context: Context, tmpDir: TemporaryFolder, -) { +): Application { val app = mock() + val appInfo = + ApplicationInfo().apply { + dataDir = tmpDir.newFolder().absolutePath + } whenever(context.applicationContext).thenReturn(app) + whenever(app.applicationInfo).thenReturn(appInfo) whenever(app.getDir(any(), any())).thenReturn(tmpDir.newFolder()) whenever(app.cacheDir).thenReturn(tmpDir.newFolder()) val sharedPreferences = mock() whenever(app.getSharedPreferences(any(), any())).thenReturn(sharedPreferences) + return app } public fun mockPermission( From 0f5332a2a29f01f8146064da8ec2c26cd286f65a Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 1 Sep 2026 18:30:11 -0400 Subject: [PATCH 2/2] fix(android): move setup storage I/O off main thread --- .changeset/calm-dingos-setup.md | 2 +- .../com/posthog/android/PostHogAndroid.kt | 24 +++++--- .../internal/PostHogSharedPreferences.kt | 38 ++++++++++--- .../replay/PostHogReplayBufferQueue.kt | 6 +- .../android/replay/PostHogReplayQueue.kt | 5 ++ .../com/posthog/android/PostHogAndroidTest.kt | 57 ++++++++++++++++++- .../test/java/com/posthog/android/Utils.kt | 1 - .../replay/PostHogReplayBufferQueueTest.kt | 2 +- .../android/replay/PostHogReplayQueueTest.kt | 25 +++++++- 9 files changed, 133 insertions(+), 27 deletions(-) diff --git a/.changeset/calm-dingos-setup.md b/.changeset/calm-dingos-setup.md index 407ab3a0b..df3180ef5 100644 --- a/.changeset/calm-dingos-setup.md +++ b/.changeset/calm-dingos-setup.md @@ -2,4 +2,4 @@ 'posthog-android': patch --- -Avoid synchronous disk access while resolving the legacy event queue during SDK setup. +Move Android storage initialization off the SDK setup thread. diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index f1ce24052..2df23ee95 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -28,8 +28,11 @@ import com.posthog.android.surveys.PostHogSurveysIntegration import com.posthog.internal.PostHogDeviceDateProvider import com.posthog.internal.PostHogNoOpLogger import com.posthog.internal.PostHogSessionManager +import com.posthog.internal.PostHogThreadFactory import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.io.File +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors // Previously accessed via `Context.getDir`, which prefixes names with "app_". private const val LEGACY_STORAGE_DIRECTORY = "app_app_posthog-disk-queue" @@ -43,6 +46,8 @@ private const val LEGACY_STORAGE_DIRECTORY = "app_app_posthog-disk-queue" public class PostHogAndroid private constructor() { public companion object { private val lock = Any() + private val storageExecutor: ExecutorService = + Executors.newSingleThreadExecutor(PostHogThreadFactory("PostHogStorageThread")) /** * Sets up the SDK and stores it as the global singleton. @@ -118,14 +123,15 @@ public class PostHogAndroid private constructor() { } val legacyPath = File(context.applicationInfo.dataDir, LEGACY_STORAGE_DIRECTORY) - val path = File(context.cacheDir, "posthog-disk-queue") - val replayPath = File(context.cacheDir, "posthog-disk-replay-queue") - val logsPath = File(context.cacheDir, "posthog-disk-logs-queue") config.legacyStoragePrefix = config.legacyStoragePrefix ?: legacyPath.absolutePath - config.storagePrefix = config.storagePrefix ?: path.absolutePath - config.replayStoragePrefix = config.replayStoragePrefix ?: replayPath.absolutePath - config.logsStoragePrefix = config.logsStoragePrefix ?: logsPath.absolutePath - val preferences = config.cachePreferences ?: PostHogSharedPreferences(context, config) + if (config.storagePrefix == null || config.replayStoragePrefix == null || config.logsStoragePrefix == null) { + val cacheDir = storageExecutor.submit { context.cacheDir }.get() + config.storagePrefix = config.storagePrefix ?: File(cacheDir, "posthog-disk-queue").absolutePath + config.replayStoragePrefix = + config.replayStoragePrefix ?: File(cacheDir, "posthog-disk-replay-queue").absolutePath + config.logsStoragePrefix = config.logsStoragePrefix ?: File(cacheDir, "posthog-disk-logs-queue").absolutePath + } + val preferences = config.cachePreferences ?: PostHogSharedPreferences(context, config, executor = storageExecutor) config.cachePreferences = preferences // Defaults to PostHogDeviceDateProvider when api < 33 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -156,7 +162,9 @@ public class PostHogAndroid private constructor() { val releaseIdentifierFallback = "$packageName@$versionName+$buildNumber" val metaPropertiesApplier = PostHogMetaPropertiesApplier() - metaPropertiesApplier.applyToConfig(context, config, releaseIdentifierFallback) + storageExecutor.submit { + metaPropertiesApplier.applyToConfig(context, config, releaseIdentifierFallback) + }.get() // Wire session replay sample rate provider so the core SDK can read the local value config.sampleRateProvider = { config.sessionReplayConfig.sampleRate } diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogSharedPreferences.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogSharedPreferences.kt index b98a7dba3..2f56ed6bd 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogSharedPreferences.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogSharedPreferences.kt @@ -8,6 +8,8 @@ import com.posthog.internal.PostHogPreferences import com.posthog.internal.PostHogPreferences.Companion.ALL_INTERNAL_KEYS import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.STRINGIFIED_KEYS +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService /** * Reads and writes to the SDKs shared preferences @@ -22,11 +24,13 @@ import com.posthog.internal.PostHogPreferences.Companion.STRINGIFIED_KEYS * @property context the App Context * @property config the Config * @param sharedPreferences The SharedPreferences, defaults to context.getSharedPreferences(...) + * @param executor Executor used to resolve and load SharedPreferences without filesystem access on the caller's thread. */ internal class PostHogSharedPreferences( private val context: Context, private val config: PostHogAndroidConfig, sharedPreferences: SharedPreferences? = null, + private val executor: ExecutorService? = null, ) : PostHogPreferences { private val lock = Any() @@ -47,13 +51,7 @@ internal class PostHogSharedPreferences( sharedPreferences?.let { return it } synchronized(lock) { sharedPreferences?.let { return it } - val prefs = - try { - context.getSharedPreferences("posthog-android-${config.apiKey}", MODE_PRIVATE) - } catch (e: IllegalStateException) { - config.logger.log("Shared preferences are not available until the device is unlocked (Direct Boot): $e.") - return null - } ?: return null + val prefs = resolveSharedPreferences() ?: return null sharedPreferences = prefs val clearExcept = pendingClearExcept pendingClearExcept = null @@ -70,6 +68,32 @@ internal class PostHogSharedPreferences( } } + private fun resolveSharedPreferences(): SharedPreferences? { + return try { + if (executor != null) { + executor.submit { loadSharedPreferences() }.get() + } else { + loadSharedPreferences() + } + } catch (e: Throwable) { + val cause = if (e is ExecutionException) e.cause ?: e else e + if (cause is IllegalStateException) { + config.logger.log("Shared preferences are not available until the device is unlocked (Direct Boot): $cause.") + null + } else { + throw cause + } + } + } + + private fun loadSharedPreferences(): SharedPreferences? { + return context.getSharedPreferences("posthog-android-${config.apiKey}", MODE_PRIVATE)?.also { + // SharedPreferences loads its file asynchronously. Force that load to finish on the + // storage executor so the first read on the setup thread only accesses memory. + it.all + } + } + override fun isAvailable(): Boolean { return getSharedPrefs() != null } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayBufferQueue.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayBufferQueue.kt index 89c9bf4c0..1d18f6785 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayBufferQueue.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayBufferQueue.kt @@ -38,11 +38,7 @@ internal class PostHogReplayBufferQueue( maxOf(newestTs - oldestTs, 0) } - init { - setup() - } - - private fun setup() { + internal fun setup() { // Clear any leftover buffer from previous sessions — if they're still here, // they didn't meet the minimum duration threshold and should be discarded. deleteDirectorySafely(bufferDir) diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayQueue.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayQueue.kt index 3d5f011d4..c8cd1aeef 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayQueue.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayQueue.kt @@ -4,6 +4,7 @@ import com.posthog.PostHogConfig import com.posthog.PostHogEvent import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.executeSafely +import com.posthog.internal.submitSyncSafely import java.io.File import java.util.concurrent.ExecutorService @@ -34,6 +35,10 @@ internal class PostHogReplayQueue internal constructor( }, ) + init { + executor.submitSyncSafely { bufferQueue.setup() } + } + internal var bufferDelegate: PostHogReplayBufferDelegate? = null /** diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index 5b0bf0165..f9806c08b 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -1,6 +1,8 @@ package com.posthog.android import android.content.Context +import android.content.SharedPreferences +import android.content.res.AssetManager import androidx.test.ext.junit.runners.AndroidJUnit4 import com.posthog.PostHog import com.posthog.android.errortracking.PostHogNativeCrashIntegration @@ -21,8 +23,12 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import java.io.File +import java.io.FileNotFoundException +import java.util.concurrent.atomic.AtomicReference import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -99,14 +105,59 @@ internal class PostHogAndroidTest { } @Test - fun `sets storage path`() { + fun `sets storage paths from one cache directory resolution`() { val config = PostHogAndroidConfig(API_KEY) + val cacheDir = tmpDir.newFolder() + val app = mockContextAppStart(context, tmpDir) + whenever(app.cacheDir).thenReturn(cacheDir) - mockContextAppStart(context, tmpDir) + PostHogAndroid.setup(context, config) + + verify(app, times(1)).cacheDir + assertEquals(File(cacheDir, "posthog-disk-queue").absolutePath, config.storagePrefix) + assertEquals(File(cacheDir, "posthog-disk-replay-queue").absolutePath, config.replayStoragePrefix) + assertEquals(File(cacheDir, "posthog-disk-logs-queue").absolutePath, config.logsStoragePrefix) + } + + @Test + fun `resolves Android storage off the setup thread`() { + val config = PostHogAndroidConfig(API_KEY) + val app = mockContextAppStart(context, tmpDir) + val setupThread = Thread.currentThread() + val cacheThread = AtomicReference() + val preferencesThread = AtomicReference() + val preferencesLoadThread = AtomicReference() + val assetsThread = AtomicReference() + val sharedPreferences = mock() + val assets = mock() + whenever(sharedPreferences.all).thenAnswer { + preferencesLoadThread.compareAndSet(null, Thread.currentThread()) + emptyMap() + } + whenever(app.cacheDir).thenAnswer { + cacheThread.set(Thread.currentThread()) + tmpDir.newFolder() + } + whenever(app.getSharedPreferences(any(), any())).thenAnswer { + preferencesThread.set(Thread.currentThread()) + sharedPreferences + } + whenever(app.assets).thenReturn(assets) + whenever(assets.open(any())).thenAnswer { + assetsThread.set(Thread.currentThread()) + throw FileNotFoundException() + } PostHogAndroid.setup(context, config) - assertNotNull(config.storagePrefix) + assertNotNull(cacheThread.get()) + assertNotNull(preferencesThread.get()) + assertNotNull(preferencesLoadThread.get()) + assertNotNull(assetsThread.get()) + assertTrue(cacheThread.get() !== setupThread) + assertTrue(preferencesThread.get() !== setupThread) + assertTrue(preferencesLoadThread.get() !== setupThread) + assertTrue(assetsThread.get() !== setupThread) } @Test diff --git a/posthog-android/src/test/java/com/posthog/android/Utils.kt b/posthog-android/src/test/java/com/posthog/android/Utils.kt index d05fcef7c..c0b881822 100644 --- a/posthog-android/src/test/java/com/posthog/android/Utils.kt +++ b/posthog-android/src/test/java/com/posthog/android/Utils.kt @@ -116,7 +116,6 @@ public fun mockContextAppStart( } whenever(context.applicationContext).thenReturn(app) whenever(app.applicationInfo).thenReturn(appInfo) - whenever(app.getDir(any(), any())).thenReturn(tmpDir.newFolder()) whenever(app.cacheDir).thenReturn(tmpDir.newFolder()) val sharedPreferences = mock() whenever(app.getSharedPreferences(any(), any())).thenReturn(sharedPreferences) diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayBufferQueueTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayBufferQueueTest.kt index 7d67b539a..0bbfbb7e1 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayBufferQueueTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayBufferQueueTest.kt @@ -113,7 +113,7 @@ internal class PostHogReplayBufferQueueTest { config: PostHogConfig = PostHogConfig(API_KEY), ): PostHogReplayBufferQueue { val dir = bufferDir ?: File(tmpDir.newFolder(), "buffer") - return PostHogReplayBufferQueue(config, dir) + return PostHogReplayBufferQueue(config, dir).also { it.setup() } } private fun createExecutor(): ExecutorService { diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayQueueTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayQueueTest.kt index 00434c85c..01fcf0b76 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayQueueTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayQueueTest.kt @@ -20,6 +20,7 @@ import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue internal class PostHogReplayQueueTest { @@ -78,13 +79,21 @@ internal class PostHogReplayQueueTest { @Volatile private var shutdown = false + private var executeNextInline = true val queuedTaskCount: Int get() = tasks.size override fun execute(command: Runnable) { if (!shutdown) { - tasks.add(command) + // Queue construction synchronously submits buffer setup. Complete that first task + // inline, then pause the operations each test controls explicitly. + if (executeNextInline) { + executeNextInline = false + command.run() + } else { + tasks.add(command) + } } } @@ -190,6 +199,20 @@ internal class PostHogReplayQueueTest { ) } + @Test + fun `initializes replay buffer before returning`() { + val storagePrefix = File(tmpDir.newFolder(), "replay").absolutePath + val bufferDir = File("$storagePrefix-buffer", API_KEY) + bufferDir.mkdirs() + val leftover = File(bufferDir, "leftover.event").apply { writeText("old data") } + + val queue = createReplayQueue(createFakeQueue(), storagePrefix) + + assertFalse(leftover.exists()) + assertTrue(bufferDir.exists()) + assertEquals(0, queue.bufferDepth) + } + @Test fun `add routes to buffer when delegate isBuffering is true`() { val fakeInnerQueue = createFakeQueue()