Skip to content
Draft
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/gentle-turtles-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-android": minor
---

Resolve `$locale` against the app’s declared supported languages and explicit default when available. Preserve the existing process-locale value when resolution is unavailable.
3 changes: 2 additions & 1 deletion buildSrc/src/main/java/PosthogBuildConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ object PosthogBuildConfig {

val OKHTTP = "4.12.0"
val CURTAINS = "1.2.5"
val ANDROIDX_CORE = "1.5.0"
val ANDROIDX_CORE = "1.9.0"
val ANDROIDX_TEST = "1.5.0"
val ANDROIDX_COMPOSE = "1.0.0"

// matches firebase-bom 34.16.0
Expand Down
7 changes: 4 additions & 3 deletions posthog-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${PosthogBuildConfig.Kotlin.KOTLIN}")
testImplementation("androidx.test:runner:${PosthogBuildConfig.Dependencies.ANDROIDX_RUNNER}")
testImplementation("androidx.test.ext:junit:${PosthogBuildConfig.Dependencies.ANDROIDX_JUNIT}")
testImplementation("androidx.test:core:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}")
testImplementation("androidx.test:core-ktx:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}")
testImplementation("androidx.test:rules:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}")
testImplementation("androidx.test:core:${PosthogBuildConfig.Dependencies.ANDROIDX_TEST}")
testImplementation("androidx.test:core-ktx:${PosthogBuildConfig.Dependencies.ANDROIDX_TEST}")
testImplementation("androidx.test:rules:${PosthogBuildConfig.Dependencies.ANDROIDX_TEST}")
testImplementation("androidx.appcompat:appcompat:1.7.0")
testImplementation("org.robolectric:robolectric:${PosthogBuildConfig.Dependencies.ROBOLECTRIC}")
testImplementation("androidx.activity:activity-compose:1.13.0")
testImplementation(platform("androidx.compose:compose-bom:2026.06.01"))
Expand Down
6 changes: 6 additions & 0 deletions posthog-android/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@
-keepnames class com.google.firebase.messaging.FirebaseMessaging
##---------------End: proguard configuration for Firebase Messaging (compileOnly) ----------

# Optional public AppCompat locale getter used on Android versions before 13.
-if class androidx.appcompat.app.AppCompatDelegate
-keep,allowoptimization class androidx.appcompat.app.AppCompatDelegate {
public static androidx.core.os.LocaleListCompat getApplicationLocales();
}

# Dead-tap response observation classifies only these known library View implementations.
# Preserve names, not members or unused classes; app-defined custom drawing still fails closed.
-keepnames class androidx.appcompat.widget.* extends android.view.View
Expand Down
131 changes: 69 additions & 62 deletions posthog-android/gradle.lockfile

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.posthog.android.internal

import androidx.core.os.LocaleListCompat
import java.util.Locale

internal data class SupportedLocales(
val locales: List<Locale>,
val defaultLocale: Locale? = null,
)

/** Negotiates declared language support, not the language of every displayed resource. */
internal fun resolveLocale(
preferences: List<Locale>,
supported: SupportedLocales,
): Locale? {
for (preferred in preferences) {
val match =
supported.locales.firstOrNull { it == preferred }
?: supported.locales.firstOrNull {
LocaleListCompat.matchesLanguageAndScript(it, preferred)
}
if (match != null) return match
}
return supported.defaultLocale?.takeIf { it in supported.locales }
}

internal fun LocaleListCompat.toLocales(): List<Locale> = (0 until size()).mapNotNull { this[it] }
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import android.content.pm.PackageInfo
import android.os.Build
import com.posthog.android.PostHogAndroidConfig
import com.posthog.internal.PostHogContext
import java.util.Locale
import java.util.TimeZone

/**
Expand All @@ -20,6 +19,8 @@ internal class PostHogAndroidContext(
private val packageInfoProvider: () -> PackageInfo? = { getPackageInfo(context, config) },
private val networkPropertiesProvider: () -> Map<String, Any>,
) : PostHogContext {
private val localeProvider = PostHogLocaleProvider(context, config)

private val cacheSdkInfo by lazy {
val sdkInfo = mutableMapOf<String, Any>()

Expand Down Expand Up @@ -67,7 +68,7 @@ internal class PostHogAndroidContext(

override fun getDynamicContext(): Map<String, Any> {
val dynamicContext = mutableMapOf<String, Any>()
dynamicContext["\$locale"] = "${Locale.getDefault().language}-${Locale.getDefault().country}"
dynamicContext["\$locale"] = localeProvider.getLocale()
System.getProperty("http.agent")?.let {
dynamicContext["\$user_agent"] = it
// Mirrored into $raw_user_agent, the standardized property PostHog's
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package com.posthog.android.internal

import android.app.LocaleConfig
import android.app.LocaleManager
import android.content.Context
import android.content.res.Resources
import android.os.Build
import android.util.AttributeSet
import androidx.annotation.RequiresApi
import androidx.core.os.ConfigurationCompat
import androidx.core.os.LocaleListCompat
import com.posthog.android.PostHogAndroidConfig
import org.xmlpull.v1.XmlPullParser
import java.util.Locale

/** Reads packaged declarations once, but refreshes preferences and runtime overrides per event. */
internal class PostHogLocaleProvider(
private val context: Context,
private val config: PostHogAndroidConfig,
) {
private val manifestSupport by lazy {
try {
readManifestLocaleConfig(context)
} catch (e: Throwable) {
config.logger.log("Unable to read the app locale declaration: $e.")
null
}
}
private val platformSupport by lazy {
if (Build.VERSION.SDK_INT >= 33) readPlatformSupport() else null
}
private val appCompatLocales = OptionalAppCompatLocales()

fun getLocale(): String {
try {
val supported = if (Build.VERSION.SDK_INT >= 33) currentPlatformSupport() else manifestSupport
if (supported != null) {
resolveLocale(requestedLocales(), supported)?.let { return it.toLanguageTag() }
}
} catch (e: Throwable) {
config.logger.log("Unable to resolve the app locale: $e.")
}
val locale = Locale.getDefault()
return "${locale.language}-${locale.country}"
}

private fun requestedLocales(): List<Locale> {
if (Build.VERSION.SDK_INT >= 33) {
val manager = context.getSystemService(LocaleManager::class.java)
val app = manager.applicationLocales
val locales = if (!app.isEmpty) app else manager.systemLocales
return (0 until locales.size()).map { locales[it] }
}
return appCompatLocales.getLocales().ifEmpty {
ConfigurationCompat.getLocales(Resources.getSystem().configuration).toLocales()
}
}

@RequiresApi(33)
private fun readPlatformSupport(): SupportedLocales? {
val localeConfig =
if (Build.VERSION.SDK_INT >= 34) {
LocaleConfig.fromContextIgnoringOverride(context)
} else {
LocaleConfig(context)
}
if (localeConfig.status != LocaleConfig.STATUS_SUCCESS) return null
val locales = localeConfig.supportedLocales ?: return null
val supported = (0 until locales.size()).map { locales[it] }
val declared = manifestSupport
val defaultLocale = declared?.takeIf { it.locales.toSet() == supported.toSet() }?.defaultLocale
return SupportedLocales(supported, defaultLocale)
}

@RequiresApi(33)
private fun currentPlatformSupport(): SupportedLocales? {
if (Build.VERSION.SDK_INT >= 34) {
val override = context.getSystemService(LocaleManager::class.java).overrideLocaleConfig

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 System lookup on every event On Android 14+, each captured event reads overrideLocaleConfig, even when the app has no locale declaration. That adds a system-service lookup to event capture where $locale previously needed only an in-process read. Avoid the per-event lookup where possible while still detecting live overrides.

Knowledge Base Used: Android SDK platform layer

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-android/src/main/java/com/posthog/android/internal/PostHogLocaleProvider.kt
Line: 78

Comment:
**System lookup on every event** On Android 14+, each captured event reads `overrideLocaleConfig`, even when the app has no locale declaration. That adds a system-service lookup to event capture where `$locale` previously needed only an in-process read. Avoid the per-event lookup where possible while still detecting live overrides.

**Knowledge Base Used:** [Android SDK platform layer](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-android/-/docs/android-sdk-platform.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

if (override != null) {
// A runtime override must not inherit the packaged declaration's default.
val locales = override.supportedLocales ?: return null
val defaultLocale = if (Build.VERSION.SDK_INT >= 35) override.defaultLocale else null
return SupportedLocales((0 until locales.size()).map { locales[it] }, defaultLocale)
}
}
return platformSupport
}
}

/** Access AppCompat's public getter without making AppCompat a runtime dependency. */
internal class OptionalAppCompatLocales(
private val className: String = "androidx.appcompat.app.AppCompatDelegate",
) {
private val getter by lazy {
try {
Class.forName(className, false, javaClass.classLoader).getMethod("getApplicationLocales")
} catch (_: ReflectiveOperationException) {
null
} catch (_: LinkageError) {
null
}
}

fun getLocales(): List<Locale> {
return try {
(getter?.invoke(null) as? LocaleListCompat)?.toLocales().orEmpty()
} catch (_: Exception) {
emptyList()
} catch (_: LinkageError) {
emptyList()
}
}
}

private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"

internal fun readManifestLocaleConfig(context: Context): SupportedLocales? {
val resourceId =
context.assets.openXmlResourceParser("AndroidManifest.xml").use { parser ->
var id = 0
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == "application") {
id = parser.getAttributeResourceValue(ANDROID_NAMESPACE, "localeConfig", 0)
break
}
parser.next()
}
id
}
if (resourceId == 0) return null
return context.resources.getXml(resourceId).use { parseLocaleConfig(it, context::getString) }
}

internal fun parseLocaleConfig(
parser: XmlPullParser,
resolveString: (Int) -> String,
): SupportedLocales? {
fun attribute(name: String): String? {
val reference = (parser as? AttributeSet)?.getAttributeResourceValue(ANDROID_NAMESPACE, name, 0) ?: 0
return if (reference != 0) resolveString(reference) else parser.getAttributeValue(ANDROID_NAMESPACE, name)
}

while (parser.eventType != XmlPullParser.START_TAG && parser.eventType != XmlPullParser.END_DOCUMENT) parser.next()
if (parser.eventType != XmlPullParser.START_TAG || parser.name != "locale-config") return null
val rootDepth = parser.depth
val defaultLocale = attribute("defaultLocale")?.let { Locale.Builder().setLanguageTag(it).build() }
val locales = mutableListOf<Locale>()
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.END_TAG && parser.depth == rootDepth) break
if (parser.eventType == XmlPullParser.START_TAG && parser.depth == rootDepth + 1 && parser.name == "locale") {
val tag = attribute("name") ?: return null
val locale = Locale.Builder().setLanguageTag(tag).build()
if (locale.language.isEmpty()) return null
locales.add(locale)
}
}
if (defaultLocale != null && defaultLocale !in locales) return null
return SupportedLocales(locales.distinct(), defaultLocale)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.posthog.android.internal

import android.util.Xml
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [24, 32, 33, 35])
internal class LocaleResolutionTest {
private fun locales(vararg tags: String) = tags.map(Locale::forLanguageTag)

@Test
fun `respects preference order and matches regional languages`() {
val supported = SupportedLocales(locales("en", "de"))
assertEquals(Locale.GERMAN, resolveLocale(locales("ru-RU", "de-CH", "en-US"), supported))
}

@Test
fun `prefers exact support before broad regional matching`() {
val supported = SupportedLocales(locales("en-US", "en-GB"))
assertEquals(Locale.UK, resolveLocale(locales("en-GB"), supported))
}

@Test
fun `preserves script distinctions`() {
val supported = SupportedLocales(locales("zh-Hans", "zh-Hant"))
assertEquals("zh-Hant", resolveLocale(locales("zh-TW"), supported)?.toLanguageTag())
assertEquals("zh-Hans", resolveLocale(locales("zh-CN"), supported)?.toLanguageTag())
assertNull(resolveLocale(locales("sr-Latn"), SupportedLocales(locales("sr-Cyrl"))))
}

@Test
fun `pseudo locales require an exact match`() {
assertNull(resolveLocale(locales("en-XA"), SupportedLocales(locales("en"))))
assertEquals("en-XA", resolveLocale(locales("en-XA"), SupportedLocales(locales("en", "en-XA")))?.toLanguageTag())
}

@Test
fun `uses declared fallback only when supported`() {
assertEquals(Locale.ENGLISH, resolveLocale(locales("de-CH"), SupportedLocales(locales("en"), Locale.ENGLISH)))
assertNull(resolveLocale(locales("de-CH"), SupportedLocales(locales("en"))))
assertNull(resolveLocale(locales("ru"), SupportedLocales(locales("de"), Locale.ENGLISH)))
assertNull(resolveLocale(locales("de"), SupportedLocales(emptyList())))
}

@Test
@Config(sdk = [23])
fun `matches basic language preferences on Android 6`() {
assertEquals(Locale.GERMAN, resolveLocale(locales("de-CH"), SupportedLocales(locales("en", "de"))))
assertEquals(Locale.ENGLISH, resolveLocale(locales("ru"), SupportedLocales(locales("en"), Locale.ENGLISH)))
}

private fun parse(xml: String): SupportedLocales? {
val parser = Xml.newPullParser().apply { setInput(xml.reader()) }
return parseLocaleConfig(parser) { error("Unexpected resource reference $it") }
}

@Test
fun `parses declared locales and explicit default`() {
val supported =
parse(
"""
<locale-config xmlns:android="http://schemas.android.com/apk/res/android" android:defaultLocale="en">
<locale android:name="en"/>
<locale android:name="de"/>
</locale-config>
""".trimIndent(),
)
assertEquals(SupportedLocales(locales("en", "de"), Locale.ENGLISH), supported)
}

@Test
fun `does not infer default from declaration order`() {
val supported =
parse(
"""
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="de"/>
<locale android:name="en"/>
</locale-config>
""".trimIndent(),
)
assertEquals(SupportedLocales(locales("de", "en")), supported)
}

@Test
fun `rejects invalid root and default`() {
assertNull(
parse(
"""
<other xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="de"/>
</other>
""".trimIndent(),
),
)
assertNull(
parse(
"""
<locale-config xmlns:android="http://schemas.android.com/apk/res/android" android:defaultLocale="fr">
<locale android:name="en"/>
</locale-config>
""".trimIndent(),
),
)
}

@Test
fun `ignores locales nested inside unknown elements`() {
assertEquals(
SupportedLocales(locales("en")),
parse(
"""
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<unknown>
<locale android:name="de"/>
</unknown>
<locale android:name="en"/>
</locale-config>
""".trimIndent(),
),
)
}
}
Loading
Loading