Skip to content
Merged
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-clocks-rest.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
marandaneto marked this conversation as resolved.
Comment thread
marandaneto marked this conversation as resolved.
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.")
Expand Down
22 changes: 5 additions & 17 deletions posthog-android/src/test/java/com/posthog/android/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,30 +52,19 @@ public fun mockScreenTitle(
): Activity {
val activity = mock<Activity>()
val pm = mock<PackageManager>()
val ac =
mock<ActivityInfo>().apply {
name = activityName
}
val appInfo = mock<ApplicationInfo>()

whenever(ac.loadLabel(any())).thenReturn(title)
whenever(appInfo.loadLabel(any())).thenReturn(applicationLabel)

if (throws) {
whenever(
pm.getActivityInfo(
any(),
any<Int>(),
),
).thenThrow(PackageManager.NameNotFoundException())
whenever(activity.title).thenThrow(IllegalStateException("title unavailable"))
} else {
whenever(pm.getActivityInfo(any(), any<Int>())).thenReturn(ac)
whenever(activity.title).thenReturn(title)
}

whenever(pm.getApplicationInfo(any(), any<Int>())).thenReturn(appInfo)
whenever(appInfo.loadLabel(any())).thenReturn(applicationLabel)

val component = mock<ComponentName>()
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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>()
val resources = mock<Resources>()
val packageManager = mock<PackageManager>()
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)))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Int>())
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,
Expand Down
Loading