Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/calm-dingos-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-android': patch
---

Move Android storage initialization off the SDK setup thread.
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ 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"

/**
* Main entry point for the Android SDK.
Expand All @@ -40,6 +46,8 @@ import java.io.File
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.
Expand Down Expand Up @@ -114,15 +122,16 @@ public class PostHogAndroid private constructor() {
config.networkStatus = PostHogAndroidNetworkStatus(context)
}

val legacyPath = context.getDir("app_posthog-disk-queue", Context.MODE_PRIVATE)
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")
val legacyPath = File(context.applicationInfo.dataDir, LEGACY_STORAGE_DIRECTORY)
Comment thread
dustinbyrne marked this conversation as resolved.
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<File> { 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) {
Expand Down Expand Up @@ -153,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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -70,6 +68,32 @@ internal class PostHogSharedPreferences(
}
}

private fun resolveSharedPreferences(): SharedPreferences? {
return try {
if (executor != null) {
executor.submit<SharedPreferences?> { 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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -34,6 +35,10 @@ internal class PostHogReplayQueue internal constructor(
},
)

init {
executor.submitSyncSafely { bufferQueue.setup() }
}

internal var bufferDelegate: PostHogReplayBufferDelegate? = null

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,7 +20,15 @@ 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.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
Expand Down Expand Up @@ -82,25 +92,72 @@ 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
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<Thread>()
val preferencesThread = AtomicReference<Thread>()
val preferencesLoadThread = AtomicReference<Thread>()
val assetsThread = AtomicReference<Thread>()
val sharedPreferences = mock<SharedPreferences>()
val assets = mock<AssetManager>()
whenever(sharedPreferences.all).thenAnswer {
preferencesLoadThread.compareAndSet(null, Thread.currentThread())
emptyMap<String, Any>()
}
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<String>())).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
Expand Down
9 changes: 7 additions & 2 deletions posthog-android/src/test/java/com/posthog/android/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,18 @@ public fun Context.mockDisplayMetrics() {
public fun mockContextAppStart(
context: Context,
tmpDir: TemporaryFolder,
) {
): Application {
val app = mock<Application>()
val appInfo =
ApplicationInfo().apply {
dataDir = tmpDir.newFolder().absolutePath
}
whenever(context.applicationContext).thenReturn(app)
whenever(app.getDir(any(), any())).thenReturn(tmpDir.newFolder())
whenever(app.applicationInfo).thenReturn(appInfo)
whenever(app.cacheDir).thenReturn(tmpDir.newFolder())
val sharedPreferences = mock<SharedPreferences>()
whenever(app.getSharedPreferences(any(), any())).thenReturn(sharedPreferences)
return app
}

public fun mockPermission(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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()
Expand Down