diff --git a/.changeset/calm-clocks-rest.md b/.changeset/calm-clocks-rest.md new file mode 100644 index 00000000..183ca370 --- /dev/null +++ b/.changeset/calm-clocks-rest.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +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. 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 ed2c751d..a6a7227a 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 e93c682a..5cfbdf4f 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 d0bc512c..85d27b37 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 @@ -10,10 +10,11 @@ import java.util.Date /** * 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 + * [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 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 a30c22d1..6ffe7854 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 a43f7e41..a1575aae 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 00000000..9dbdefb4 --- /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 117fdc15..a9387521 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,