From 1259a40c7969658ec304c2937c6fe9a269345b12 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 27 Jul 2026 11:15:04 +0700 Subject: [PATCH 1/3] perf: avoid remaining main-thread IPC candidates --- .changeset/calm-clocks-rest.md | 5 + .../posthog/android/internal/DeviceUtils.kt | 9 +- .../android/internal/PostHogAndroidContext.kt | 2 + .../internal/PostHogAndroidDateProvider.kt | 56 ++++++-- .../android/internal/PostHogAndroidUtils.kt | 9 +- .../test/java/com/posthog/android/Utils.kt | 22 +-- .../android/internal/DeviceUtilsTest.kt | 60 +++++++++ ...ctivityLifecycleCallbackIntegrationTest.kt | 28 +++- .../PostHogAndroidDateProviderTest.kt | 126 +++++++++++++++++- 9 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 .changeset/calm-clocks-rest.md create mode 100644 posthog-android/src/test/java/com/posthog/android/internal/DeviceUtilsTest.kt diff --git a/.changeset/calm-clocks-rest.md b/.changeset/calm-clocks-rest.md new file mode 100644 index 000000000..ea60ea799 --- /dev/null +++ b/.changeset/calm-clocks-rest.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Avoid remaining main-thread IPC candidates in network time, screen autocapture, and device type detection. diff --git a/posthog-android/src/main/java/com/posthog/android/internal/DeviceUtils.kt b/posthog-android/src/main/java/com/posthog/android/internal/DeviceUtils.kt index ed2c751d0..a6a7227ae 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/DeviceUtils.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/DeviceUtils.kt @@ -1,6 +1,5 @@ package com.posthog.android.internal -import android.app.UiModeManager import android.content.Context import android.content.res.Configuration import android.os.Build @@ -70,11 +69,9 @@ internal fun getDeviceType(context: Context): String? { return "TV" } - val uiManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? - uiManager?.let { - if (it.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) { - return "TV" - } + val uiModeType = context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK + if (uiModeType == Configuration.UI_MODE_TYPE_TELEVISION) { + return "TV" } val deviceTypeFromResourceConfiguration = getDeviceTypeFromResourceConfiguration(context) diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidContext.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidContext.kt index e93c682a1..5cfbdf4fd 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidContext.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidContext.kt @@ -96,6 +96,8 @@ internal class PostHogAndroidContext( } context.telephonyManager()?.let { + // TelephonyCallback requires location permission to expose the operator name. Keep this + // property read, which primarily uses telephony system properties and cached mapping. val networkOperatorName = it.networkOperatorName if (!networkOperatorName.isNullOrEmpty()) { dynamicContext["\$network_carrier"] = networkOperatorName diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt index d0bc512cb..f1efe1787 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt @@ -4,17 +4,21 @@ import android.os.Build import android.os.SystemClock import androidx.annotation.RequiresApi import com.posthog.internal.PostHogDateProvider +import com.posthog.internal.PostHogThreadFactory import java.time.Clock import java.util.Date +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean /** * Provides the current time, corrected to network time when available. * * [Clock.millis] on the clock returned by [SystemClock.currentNetworkTimeClock] performs a Binder * IPC to a system service on every call, so querying it on each timestamp (e.g. on every touch - * event) blocks the calling thread and can ANR under load. Instead the network time is sampled at - * most once per [refreshIntervalMs] and subsequent timestamps are derived from the monotonic - * [android.os.SystemClock.elapsedRealtime] delta since that anchor. + * event) blocks the calling thread and can ANR under load. Instead the network time is sampled on + * [refreshExecutor] at most once per [refreshIntervalMs], and subsequent timestamps are derived + * from the monotonic [android.os.SystemClock.elapsedRealtime] delta since that anchor. * * Once a sample has succeeded, later sampling failures extend the existing anchor rather than * falling back to the system wall clock: the platform's network clock derives time the same way, @@ -26,6 +30,7 @@ internal class PostHogAndroidDateProvider( runCatching { SystemClock.currentNetworkTimeClock() }.getOrNull(), private val elapsedRealtimeMs: () -> Long = SystemClock::elapsedRealtime, private val refreshIntervalMs: Long = REFRESH_INTERVAL_MS, + private val refreshExecutor: Executor = NETWORK_TIME_EXECUTOR, ) : PostHogDateProvider { // A network-time sample and the elapsedRealtime at which it was taken, published together as a // single immutable value so readers never observe a mixed (torn) anchor. @@ -40,6 +45,8 @@ internal class PostHogAndroidDateProvider( @Volatile private var lastAttemptElapsedMs: Long? = null + private val refreshInFlight = AtomicBoolean(false) + override fun currentDate(): Date { return Date(currentTimeMillis()) } @@ -56,17 +63,40 @@ internal class PostHogAndroidDateProvider( val lastAttempt = lastAttemptElapsedMs val mayAttempt = lastAttempt == null || elapsed - lastAttempt >= refreshIntervalMs if (anchorStale && mayAttempt) { - lastAttemptElapsedMs = elapsed - val networkNow = runCatching { clock.millis() }.getOrNull() - if (networkNow != null) { - // re-read the monotonic clock: the sample corresponds to the moment millis() - // returned, and the IPC itself may have blocked long enough to skew the anchor - anchor = Anchor(networkNow, elapsedRealtimeMs()) - return networkNow - } + refreshAnchor(clock) } + val latest = anchor - return if (latest != null) latest.networkMs + (elapsed - latest.elapsedMs) else System.currentTimeMillis() + return if (latest != null) { + val latestElapsed = if (latest === current) elapsed else elapsedRealtimeMs() + latest.networkMs + (latestElapsed - latest.elapsedMs) + } else { + System.currentTimeMillis() + } + } + + private fun refreshAnchor(clock: Clock) { + if (!refreshInFlight.compareAndSet(false, true)) { + return + } + + try { + refreshExecutor.execute { + try { + lastAttemptElapsedMs = elapsedRealtimeMs() + val networkNow = runCatching { clock.millis() }.getOrNull() + if (networkNow != null) { + // Re-read the monotonic clock: the sample corresponds to the moment millis() + // returned, and the IPC itself may have blocked long enough to skew the anchor. + anchor = Anchor(networkNow, elapsedRealtimeMs()) + } + } finally { + refreshInFlight.set(false) + } + } + } catch (_: Throwable) { + refreshInFlight.set(false) + } } override fun nanoTime(): Long { @@ -75,5 +105,7 @@ internal class PostHogAndroidDateProvider( private companion object { private const val REFRESH_INTERVAL_MS = 60_000L + private val NETWORK_TIME_EXECUTOR = + Executors.newSingleThreadExecutor(PostHogThreadFactory("PostHogNetworkTimeThread")) } } diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt index a30c22d18..6ffe7854a 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt @@ -133,21 +133,20 @@ internal fun Context.telephonyManager(): TelephonyManager? { return getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager } -@Suppress("DEPRECATION") internal fun Activity.activityLabelOrName(config: PostHogAndroidConfig): String? { return try { - val activityInfo = packageManager.getActivityInfo(componentName, GET_META_DATA) - val activityLabel = activityInfo.loadLabel(packageManager).toString() + val activityLabel = title?.toString().orEmpty() + val activityName = componentName.className val applicationLabel = applicationInfo.loadLabel(packageManager).toString() if (activityLabel.isNotEmpty() && activityLabel != applicationLabel) { - if (activityLabel == activityInfo.name) { + if (activityLabel == activityName) { activityLabel.substringAfterLast('.') } else { activityLabel } } else { - activityInfo.name.substringAfterLast('.') + localClassName.substringAfterLast('.') } } catch (e: Throwable) { config.logger.log("Error getting the Activity's label or name: $e.") 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 a43f7e415..a1575aae4 100644 --- a/posthog-android/src/test/java/com/posthog/android/Utils.kt +++ b/posthog-android/src/test/java/com/posthog/android/Utils.kt @@ -8,7 +8,6 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.SharedPreferences -import android.content.pm.ActivityInfo import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager @@ -53,30 +52,19 @@ public fun mockScreenTitle( ): Activity { val activity = mock() val pm = mock() - val ac = - mock().apply { - name = activityName - } val appInfo = mock() - whenever(ac.loadLabel(any())).thenReturn(title) - whenever(appInfo.loadLabel(any())).thenReturn(applicationLabel) - if (throws) { - whenever( - pm.getActivityInfo( - any(), - any(), - ), - ).thenThrow(PackageManager.NameNotFoundException()) + whenever(activity.title).thenThrow(IllegalStateException("title unavailable")) } else { - whenever(pm.getActivityInfo(any(), any())).thenReturn(ac) + whenever(activity.title).thenReturn(title) } - - whenever(pm.getApplicationInfo(any(), any())).thenReturn(appInfo) + whenever(appInfo.loadLabel(any())).thenReturn(applicationLabel) val component = mock() + whenever(component.className).thenReturn(activityName) whenever(activity.componentName).thenReturn(component) + whenever(activity.localClassName).thenReturn(activityName) whenever(activity.packageManager).thenReturn(pm) whenever(activity.applicationInfo).thenReturn(appInfo) // Ensure applicationInfo is not null diff --git a/posthog-android/src/test/java/com/posthog/android/internal/DeviceUtilsTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/DeviceUtilsTest.kt new file mode 100644 index 000000000..9dbdefb4f --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/DeviceUtilsTest.kt @@ -0,0 +1,60 @@ +package com.posthog.android.internal + +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.content.res.Resources +import android.util.DisplayMetrics +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28]) +internal class DeviceUtilsTest { + private fun contextWithConfiguration(configuration: Configuration): Context { + val context = mock() + val resources = mock() + val packageManager = mock() + whenever(context.resources).thenReturn(resources) + whenever(context.packageManager).thenReturn(packageManager) + whenever(resources.configuration).thenReturn(configuration) + whenever(resources.displayMetrics).thenReturn(DisplayMetrics()) + whenever(packageManager.hasSystemFeature("amazon.hardware.fire_tv")).thenReturn(false) + return context + } + + @Test + fun `returns TV from the local resource configuration`() { + val configuration = Configuration() + configuration.uiMode = Configuration.UI_MODE_TYPE_TELEVISION or Configuration.UI_MODE_NIGHT_YES + val context = contextWithConfiguration(configuration) + + assertEquals("TV", getDeviceType(context)) + verify(context, never()).getSystemService(Context.UI_MODE_SERVICE) + } + + @Test + fun `keeps tablet classification for non television UI mode`() { + val configuration = Configuration() + configuration.uiMode = Configuration.UI_MODE_TYPE_NORMAL + configuration.smallestScreenWidthDp = 600 + + assertEquals("Tablet", getDeviceType(contextWithConfiguration(configuration))) + } + + @Test + fun `keeps mobile classification for non television UI mode`() { + val configuration = Configuration() + configuration.uiMode = Configuration.UI_MODE_TYPE_NORMAL + configuration.smallestScreenWidthDp = 599 + + assertEquals("Mobile", getDeviceType(contextWithConfiguration(configuration))) + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt index 117fdc15e..a93875214 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt @@ -12,6 +12,7 @@ import com.posthog.android.mockScreenTitle 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 kotlin.test.BeforeTest import kotlin.test.Test @@ -183,7 +184,32 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { } @Test - fun `onActivityStarted returns activityInfo name if labels are the same`() { + fun `onActivityStarted does not query activity info`() { + val sut = getSut() + val activity = mockScreenTitle(false, "Title", "com.example.MyActivity", "AppLabel") + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityStarted(activity) + sut.uninstall() + + verify(activity.packageManager, never()).getActivityInfo(any(), any()) + assertEquals("Title", fake.screenTitle) + } + + @Test + fun `onActivityStarted returns activity name when the label is the class name`() { + val fake = + executeCaptureScreenViewsTest( + title = "com.example.MyActivity", + activityName = "com.example.MyActivity", + ) + + assertEquals("MyActivity", fake.screenTitle) + } + + @Test + fun `onActivityStarted returns activity name if labels are the same`() { val fake = executeCaptureScreenViewsTest( captureScreenViews = true, diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt index ff851f393..3dd5020a3 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt @@ -7,6 +7,9 @@ import java.time.Clock import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset +import java.util.ArrayDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals @@ -14,6 +17,23 @@ import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) @Config(sdk = [34]) // PostHogAndroidDateProvider requires API >= TIRAMISU. internal class PostHogAndroidDateProviderTest { + private val directExecutor = Executor { it.run() } + + private class QueuedExecutor : Executor { + private val tasks = ArrayDeque() + + val pendingCount: Int + get() = tasks.size + + override fun execute(command: Runnable) { + tasks.addLast(command) + } + + fun runNext() { + tasks.removeFirst().run() + } + } + private class CountingClock(private val now: Long) : Clock() { val millisCalls = AtomicInteger(0) @@ -29,10 +49,66 @@ internal class PostHogAndroidDateProviderTest { override fun withZone(zone: ZoneId?): Clock = this } + @Test + fun `queries the network clock only through the refresh executor`() { + val clock = CountingClock(1_000_000L) + val executor = QueuedExecutor() + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { 100L }, + refreshIntervalMs = 60_000L, + refreshExecutor = executor, + ) + + repeat(50) { sut.currentTimeMillis() } + + assertEquals(0, clock.millisCalls.get()) + assertEquals(1, executor.pendingCount) + + executor.runNext() + + assertEquals(1_000_000L, sut.currentTimeMillis()) + assertEquals(1, clock.millisCalls.get()) + } + + @Test + fun `concurrent callers schedule only one network refresh`() { + val clock = CountingClock(1_000_000L) + val executor = QueuedExecutor() + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { 100L }, + refreshIntervalMs = 60_000L, + refreshExecutor = executor, + ) + val start = CountDownLatch(1) + val callers = + List(20) { + Thread { + start.await() + sut.currentTimeMillis() + }.apply { start() } + } + + start.countDown() + callers.forEach(Thread::join) + + assertEquals(0, clock.millisCalls.get()) + assertEquals(1, executor.pendingCount) + } + @Test fun `does not query the network clock on every call`() { val clock = CountingClock(1_000_000L) - val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { 100L }, refreshIntervalMs = 60_000L) + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { 100L }, + refreshIntervalMs = 60_000L, + refreshExecutor = directExecutor, + ) repeat(50) { sut.currentTimeMillis() } @@ -43,7 +119,13 @@ internal class PostHogAndroidDateProviderTest { fun `derives time from the elapsed delta between refreshes`() { val clock = CountingClock(1_000_000L) var elapsed = 100L - val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { elapsed }, + refreshIntervalMs = 60_000L, + refreshExecutor = directExecutor, + ) assertEquals(1_000_000L, sut.currentTimeMillis()) elapsed = 5_100L @@ -69,7 +151,13 @@ internal class PostHogAndroidDateProviderTest { @Test fun `does not retry the network clock on every call when sampling fails`() { val clock = ThrowingClock() - val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { 100L }, refreshIntervalMs = 60_000L) + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { 100L }, + refreshIntervalMs = 60_000L, + refreshExecutor = directExecutor, + ) repeat(50) { sut.currentTimeMillis() } @@ -80,7 +168,13 @@ internal class PostHogAndroidDateProviderTest { fun `refreshes the anchor after the interval elapses`() { val clock = CountingClock(1_000_000L) var elapsed = 100L - val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { elapsed }, + refreshIntervalMs = 60_000L, + refreshExecutor = directExecutor, + ) sut.currentTimeMillis() elapsed = 100L + 60_000L @@ -106,7 +200,13 @@ internal class PostHogAndroidDateProviderTest { override fun withZone(zone: ZoneId?): Clock = this } - val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { elapsed }, + refreshIntervalMs = 60_000L, + refreshExecutor = directExecutor, + ) sut.currentTimeMillis() elapsed += 1_000L @@ -118,7 +218,13 @@ internal class PostHogAndroidDateProviderTest { fun `retries a failed network sample after the interval elapses`() { val clock = ThrowingClock() var elapsed = 100L - val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { elapsed }, + refreshIntervalMs = 60_000L, + refreshExecutor = directExecutor, + ) sut.currentTimeMillis() elapsed += 60_000L @@ -148,7 +254,13 @@ internal class PostHogAndroidDateProviderTest { fun `a failed refresh extends the existing anchor instead of falling back to system time`() { val clock = SucceedOnceClock(1_000_000L) var elapsed = 100L - val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) + val sut = + PostHogAndroidDateProvider( + networkClock = clock, + elapsedRealtimeMs = { elapsed }, + refreshIntervalMs = 60_000L, + refreshExecutor = directExecutor, + ) assertEquals(1_000_000L, sut.currentTimeMillis()) elapsed += 65_000L From 461013509c21e6e69dbea62d0237fc6cda762997 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 27 Jul 2026 11:26:17 +0700 Subject: [PATCH 2/3] revert: keep synchronous network time cache --- .changeset/calm-clocks-rest.md | 2 +- .../internal/PostHogAndroidDateProvider.kt | 61 +++------ .../PostHogAndroidDateProviderTest.kt | 126 +----------------- 3 files changed, 23 insertions(+), 166 deletions(-) diff --git a/.changeset/calm-clocks-rest.md b/.changeset/calm-clocks-rest.md index ea60ea799..9ba172aff 100644 --- a/.changeset/calm-clocks-rest.md +++ b/.changeset/calm-clocks-rest.md @@ -2,4 +2,4 @@ "posthog-android": patch --- -Avoid remaining main-thread IPC candidates in network time, screen autocapture, and device type detection. +Avoid main-thread IPC candidates in screen autocapture and device type detection. diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt index f1efe1787..85d27b37f 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidDateProvider.kt @@ -4,21 +4,18 @@ import android.os.Build import android.os.SystemClock import androidx.annotation.RequiresApi import com.posthog.internal.PostHogDateProvider -import com.posthog.internal.PostHogThreadFactory import java.time.Clock import java.util.Date -import java.util.concurrent.Executor -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean /** * Provides the current time, corrected to network time when available. * - * [Clock.millis] on the clock returned by [SystemClock.currentNetworkTimeClock] performs a Binder - * IPC to a system service on every call, so querying it on each timestamp (e.g. on every touch - * event) blocks the calling thread and can ANR under load. Instead the network time is sampled on - * [refreshExecutor] at most once per [refreshIntervalMs], and subsequent timestamps are derived - * from the monotonic [android.os.SystemClock.elapsedRealtime] delta since that anchor. + * [Clock.millis] on the clock returned by [SystemClock.currentNetworkTimeClock] reads an OS-cached + * network-time sample; it does not make an NTP or other network request. Retrieving that cached + * sample uses Binder on Android 13-15, while Android 16 can use shared memory with a Binder fallback. + * We intentionally accept at most one synchronous IPC per [refreshIntervalMs] to keep this cache + * simple. Subsequent timestamps are derived from the monotonic + * [android.os.SystemClock.elapsedRealtime] delta since that anchor. * * Once a sample has succeeded, later sampling failures extend the existing anchor rather than * falling back to the system wall clock: the platform's network clock derives time the same way, @@ -30,7 +27,6 @@ internal class PostHogAndroidDateProvider( runCatching { SystemClock.currentNetworkTimeClock() }.getOrNull(), private val elapsedRealtimeMs: () -> Long = SystemClock::elapsedRealtime, private val refreshIntervalMs: Long = REFRESH_INTERVAL_MS, - private val refreshExecutor: Executor = NETWORK_TIME_EXECUTOR, ) : PostHogDateProvider { // A network-time sample and the elapsedRealtime at which it was taken, published together as a // single immutable value so readers never observe a mixed (torn) anchor. @@ -45,8 +41,6 @@ internal class PostHogAndroidDateProvider( @Volatile private var lastAttemptElapsedMs: Long? = null - private val refreshInFlight = AtomicBoolean(false) - override fun currentDate(): Date { return Date(currentTimeMillis()) } @@ -63,40 +57,17 @@ internal class PostHogAndroidDateProvider( val lastAttempt = lastAttemptElapsedMs val mayAttempt = lastAttempt == null || elapsed - lastAttempt >= refreshIntervalMs if (anchorStale && mayAttempt) { - refreshAnchor(clock) - } - - val latest = anchor - return if (latest != null) { - val latestElapsed = if (latest === current) elapsed else elapsedRealtimeMs() - latest.networkMs + (latestElapsed - latest.elapsedMs) - } else { - System.currentTimeMillis() - } - } - - private fun refreshAnchor(clock: Clock) { - if (!refreshInFlight.compareAndSet(false, true)) { - return - } - - try { - refreshExecutor.execute { - try { - lastAttemptElapsedMs = elapsedRealtimeMs() - val networkNow = runCatching { clock.millis() }.getOrNull() - if (networkNow != null) { - // Re-read the monotonic clock: the sample corresponds to the moment millis() - // returned, and the IPC itself may have blocked long enough to skew the anchor. - anchor = Anchor(networkNow, elapsedRealtimeMs()) - } - } finally { - refreshInFlight.set(false) - } + lastAttemptElapsedMs = elapsed + val networkNow = runCatching { clock.millis() }.getOrNull() + if (networkNow != null) { + // re-read the monotonic clock: the sample corresponds to the moment millis() + // returned, and the IPC itself may have blocked long enough to skew the anchor + anchor = Anchor(networkNow, elapsedRealtimeMs()) + return networkNow } - } catch (_: Throwable) { - refreshInFlight.set(false) } + val latest = anchor + return if (latest != null) latest.networkMs + (elapsed - latest.elapsedMs) else System.currentTimeMillis() } override fun nanoTime(): Long { @@ -105,7 +76,5 @@ internal class PostHogAndroidDateProvider( private companion object { private const val REFRESH_INTERVAL_MS = 60_000L - private val NETWORK_TIME_EXECUTOR = - Executors.newSingleThreadExecutor(PostHogThreadFactory("PostHogNetworkTimeThread")) } } diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt index 3dd5020a3..ff851f393 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogAndroidDateProviderTest.kt @@ -7,9 +7,6 @@ import java.time.Clock import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset -import java.util.ArrayDeque -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals @@ -17,23 +14,6 @@ import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) @Config(sdk = [34]) // PostHogAndroidDateProvider requires API >= TIRAMISU. internal class PostHogAndroidDateProviderTest { - private val directExecutor = Executor { it.run() } - - private class QueuedExecutor : Executor { - private val tasks = ArrayDeque() - - val pendingCount: Int - get() = tasks.size - - override fun execute(command: Runnable) { - tasks.addLast(command) - } - - fun runNext() { - tasks.removeFirst().run() - } - } - private class CountingClock(private val now: Long) : Clock() { val millisCalls = AtomicInteger(0) @@ -49,66 +29,10 @@ internal class PostHogAndroidDateProviderTest { override fun withZone(zone: ZoneId?): Clock = this } - @Test - fun `queries the network clock only through the refresh executor`() { - val clock = CountingClock(1_000_000L) - val executor = QueuedExecutor() - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { 100L }, - refreshIntervalMs = 60_000L, - refreshExecutor = executor, - ) - - repeat(50) { sut.currentTimeMillis() } - - assertEquals(0, clock.millisCalls.get()) - assertEquals(1, executor.pendingCount) - - executor.runNext() - - assertEquals(1_000_000L, sut.currentTimeMillis()) - assertEquals(1, clock.millisCalls.get()) - } - - @Test - fun `concurrent callers schedule only one network refresh`() { - val clock = CountingClock(1_000_000L) - val executor = QueuedExecutor() - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { 100L }, - refreshIntervalMs = 60_000L, - refreshExecutor = executor, - ) - val start = CountDownLatch(1) - val callers = - List(20) { - Thread { - start.await() - sut.currentTimeMillis() - }.apply { start() } - } - - start.countDown() - callers.forEach(Thread::join) - - assertEquals(0, clock.millisCalls.get()) - assertEquals(1, executor.pendingCount) - } - @Test fun `does not query the network clock on every call`() { val clock = CountingClock(1_000_000L) - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { 100L }, - refreshIntervalMs = 60_000L, - refreshExecutor = directExecutor, - ) + val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { 100L }, refreshIntervalMs = 60_000L) repeat(50) { sut.currentTimeMillis() } @@ -119,13 +43,7 @@ internal class PostHogAndroidDateProviderTest { fun `derives time from the elapsed delta between refreshes`() { val clock = CountingClock(1_000_000L) var elapsed = 100L - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { elapsed }, - refreshIntervalMs = 60_000L, - refreshExecutor = directExecutor, - ) + val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) assertEquals(1_000_000L, sut.currentTimeMillis()) elapsed = 5_100L @@ -151,13 +69,7 @@ internal class PostHogAndroidDateProviderTest { @Test fun `does not retry the network clock on every call when sampling fails`() { val clock = ThrowingClock() - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { 100L }, - refreshIntervalMs = 60_000L, - refreshExecutor = directExecutor, - ) + val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { 100L }, refreshIntervalMs = 60_000L) repeat(50) { sut.currentTimeMillis() } @@ -168,13 +80,7 @@ internal class PostHogAndroidDateProviderTest { fun `refreshes the anchor after the interval elapses`() { val clock = CountingClock(1_000_000L) var elapsed = 100L - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { elapsed }, - refreshIntervalMs = 60_000L, - refreshExecutor = directExecutor, - ) + val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) sut.currentTimeMillis() elapsed = 100L + 60_000L @@ -200,13 +106,7 @@ internal class PostHogAndroidDateProviderTest { override fun withZone(zone: ZoneId?): Clock = this } - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { elapsed }, - refreshIntervalMs = 60_000L, - refreshExecutor = directExecutor, - ) + val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) sut.currentTimeMillis() elapsed += 1_000L @@ -218,13 +118,7 @@ internal class PostHogAndroidDateProviderTest { fun `retries a failed network sample after the interval elapses`() { val clock = ThrowingClock() var elapsed = 100L - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { elapsed }, - refreshIntervalMs = 60_000L, - refreshExecutor = directExecutor, - ) + val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) sut.currentTimeMillis() elapsed += 60_000L @@ -254,13 +148,7 @@ internal class PostHogAndroidDateProviderTest { fun `a failed refresh extends the existing anchor instead of falling back to system time`() { val clock = SucceedOnceClock(1_000_000L) var elapsed = 100L - val sut = - PostHogAndroidDateProvider( - networkClock = clock, - elapsedRealtimeMs = { elapsed }, - refreshIntervalMs = 60_000L, - refreshExecutor = directExecutor, - ) + val sut = PostHogAndroidDateProvider(networkClock = clock, elapsedRealtimeMs = { elapsed }, refreshIntervalMs = 60_000L) assertEquals(1_000_000L, sut.currentTimeMillis()) elapsed += 65_000L From d3dce1eaa0ca78eafcd3939ac2b23e15d357736b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 27 Jul 2026 15:26:41 +0700 Subject: [PATCH 3/3] docs: clarify dynamic screen title behavior --- .changeset/calm-clocks-rest.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-clocks-rest.md b/.changeset/calm-clocks-rest.md index 9ba172aff..183ca3704 100644 --- a/.changeset/calm-clocks-rest.md +++ b/.changeset/calm-clocks-rest.md @@ -2,4 +2,4 @@ "posthog-android": patch --- -Avoid main-thread IPC candidates in screen autocapture and device type detection. +Avoid main-thread IPC candidates in screen autocapture and device type detection. Screen autocapture now uses the activity's current title, which may produce a different `$screen_name` for `$screen` events when apps set titles dynamically.