Skip to content

Send a proper User-Agent instead of okhttp's default - #735

Merged
adalpari merged 8 commits into
trunkfrom
add/sdk-user-agent
Oct 20, 2026
Merged

adalpari merged 8 commits into
trunkfrom
add/sdk-user-agent

Conversation

@adalpari

@adalpari adalpari commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

This PR is adding a mechanism to set the user-agent the sdk is using for its requests. Right now, it's using the OkHttp default one, and we need to inject the proper UA.

Description

Why this is needed

SDK requests currently go out with OkHttp's default User-Agent: okhttp/<version> — the SDK declares 4.12.0, but consumers routinely resolve higher, so what reaches the backend is whatever their build landed on. That string identifies the HTTP library and nothing else, so on the server side every Gravatar SDK request looks identical to every other OkHttp client on the internet — there is no way to tell which app made a call, which version it was running, or even that it came from our SDK at all.

That has three concrete costs:

  1. Traffic can't be attributed. Backend and infrastructure teams can't answer "which app is sending this?" for any SDK request.
  2. Rules can't be written. Allowlists, rate limits and WAF exceptions have nothing stable to key on, so they fall back to fingerprinting, which is fragile.
  3. Apps can't fix it themselves. This is the part that forces the change into the SDK rather than into consumers.

On point 3: the services do accept an OkHttpClient via constructor, but the Quick Editor never exposes that. QuickEditorContainer is internal with a private constructor, its init() takes only a Context, its okHttpClient is a private val built inline, and it constructs ProfileService() with no client at all. GravatarQuickEditor.show() and GravatarQuickEditorParams have no client parameter either. So a consumer using the Quick Editor has no injection point anywhere, and short of rebuilding the profile and avatar-picker UI against the raw services, cannot set a header on these requests.

This surfaced concretely while Automattic systems were investigating an edge rule on api.gravatar.com that returned 403 to the WordPress/Jetpack Android app for roughly ten months. They asked us to send an identifying User-Agent so the rule could be keyed on something stable — and we found there was no way to do it from the app at all.

Where the header is set. GravatarSdkContainer.buildOkHttpClient is the single place the SDK constructs an OkHttpClient for its API calls, and it always adds SdkVersionInterceptor. Setting the header there covers the core services and the Quick Editor's internal ProfileService() / AvatarService(), with no change to any call path and nothing for consumers to opt into.

It is only set when absent, so a consumer that passes its own client keeps its own User-Agent. OkHttp's BridgeInterceptor runs afterwards and also only fills a missing header, so this is what stops the default from going out.

Identifying the app. Gravatar.appIdentifier(name, version) lets an app state the name it is already known by server-side. The name has to be a User-Agent product token — no whitespace, no /, unaccented ASCII — and anything else throws at the call rather than failing requests later, so the string a server keys a rule on is exactly the string you passed. context() can't do that well — the launcher label is localized and usually suffixed per build type (Jetpack vs Jetpack Debug), so one app reaches the backend under several names and carries no version. context() still works and now also reads versionName from PackageManager, so existing consumers get a better UA for free.

Resulting header:

Consumer setup User-Agent
Gravatar.appIdentifier("wp-android", "27.6") wp-android/27.6 GravatarAndroid/2.5.0
Gravatar.context(context) Jetpack-Debug/27.6 GravatarAndroid/2.5.0
nothing set GravatarAndroid/2.5.0
caller passes a client with its own UA unchanged

How to use it from an Android client

Set the identifier once, early — Application.onCreate() is the natural place — and every subsequent SDK request carries it. It is a plain setter on a singleton, so it must run before the first request, but it does not have to run before SDK initialization and is cheap enough not to matter on the startup path.

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        Gravatar.appIdentifier(
            name = "wp-android",        // the name the backend already knows you by
            version = BuildConfig.VERSION_NAME,
        )
    }
}

That produces User-Agent: wp-android/27.6 GravatarAndroid/2.5.0 on every request the SDK makes, including the ones the Quick Editor makes internally.

Pick name deliberately: it should be the identifier your backend already uses for the app, stable across releases and build types, so one rule covers you everywhere. Prefer it over context(), which derives the name from the launcher label — that is localized and usually suffixed per build type, so the same app arrives as Jetpack and Jetpack Debug depending on the build.

If you have no such identifier and just want attribution, context() still works and now fills the version too:

Gravatar.context(this)   // -> "Jetpack-Debug/27.6 GravatarAndroid/2.5.0"

Calling neither is fine — you get GravatarAndroid/2.5.0, which at least identifies the SDK. And if you already pass your own OkHttpClient to ProfileService / AvatarService with a User-Agent of your own, the SDK leaves it alone, so nothing changes for you.

Gravatar.userAgent exposes the composed string if you want to reuse it on requests you make yourself.

Requests the interceptor can't reach. Two paths don't go through an SDK-built OkHttpClient, and both set the header themselves:

  • ImageDownloader downloads through DownloadManager. It sets User-Agent alongside the X-Platform / X-SDK-Version / X-Source it already sent — and X-Source now comes from Gravatar.appName rather than being re-derived from the launcher label, so a download can no longer name the app one way there and another in the User-Agent.
  • Avatars and social icons load through Coil, whose ImageLoader belongs to the host app and builds its own OkHttp client. Those requests — one per avatar rendered, so plausibly the bulk of what the SDK sends — now carry the header on the ImageRequest itself. Setting it there rather than supplying an ImageLoader of our own leaves the host's cache, interceptors and dispatchers untouched.

One name, on every header. The app name is reduced once to an RFC 9110 product token and that token is what goes out, in both X-Source and the User-Agent. This is a behaviour change for X-Source, which previously sent the launcher label verbatim: a label containing whitespace or punctuation now arrives tokenized (Jetpack (Debug) as Jetpack-Debug), and a label a token cannot represent at all — one written entirely in kana — is omitted rather than sent. Worth a look from anyone who consumes that header. It also fixes a latent crash: a non-ASCII label made OkHttp throw from inside the interceptor and fail every request the SDK made, which was already reachable on trunk through X-Source.

Gravatar.appName exposes the name the SDK sends, next to Gravatar.userAgent, so a consumer — or the Quick Editor's downloader — identifies the app the same way instead of deriving a second name of its own. It is null when there is no usable name.

gravatar/api/gravatar.api was regenerated with apiDump. The change is purely additive (appIdentifier, userAgent, appName), so it stays binary compatible.

Testing Steps

Automated:

./gradlew :gravatar:testDebugUnitTest :gravatar-ui:testDebugUnitTest \
          :gravatar-quickeditor:testDebugUnitTest detekt ktlintCheck apiCheck
./gradlew :gravatar-ui:verifyRoborazziDebug :gravatar-quickeditor:verifyRoborazziDebug -Pscreenshot

50 new tests across SdkVersionInterceptorTest, GravatarSdkContainerTest, GravatarTest, ImageDownloaderTest and a GravatarImageRequestTest per UI module; none removed. Screenshot baselines are unchanged.

Each guard is mutation-checked — reverting it fails the tests that cover it:

guard reverting it breaks
User-Agent set only when absent given a request that already has a User-Agent …
product-token reduction 6 GravatarSdkContainerTest cases
appIdentifier name validation 3 GravatarTest cases
X-Source from the token 5 SdkVersionInterceptorTest cases
X-Source on downloads ImageDownloaderTest, with expected:<wp-android> but was:<com.gravatar.quickeditor.test>
User-Agent on image loads 2 GravatarImageRequestTest cases per module

The non-ASCII case reproduces the original crash exactly: without the guard, SdkVersionInterceptorTest fails with java.lang.IllegalArgumentException: Unexpected char 0x30b0 at 0 in X-Source value: グラバタ.

Manual, from a consuming app:

  1. Call Gravatar.appIdentifier("your-app", "1.0") before opening the Quick Editor.
  2. Open the Quick Editor and watch the requests to api.gravatar.com (mitmproxy, or a logging interceptor on a client you pass in).
  3. Confirm User-Agent: your-app/1.0 GravatarAndroid/<version> on /v3/me/profile and /v3/me/avatars.
  4. Download an avatar from the picker and confirm the same header on the DownloadManager request.
  5. Render an Avatar or LargeProfile and confirm the avatar image request carries it too, rather than OkHttp's default.

The demo app shows the composed User-Agent in a bar above the tabs, with buttons for appIdentifier() and context(), if you want to see it change without a proxy.

Notes for reviewers

Two judgement calls worth a second opinion:

  • No device info in the UA. I left out Build.MODEL / Build.VERSION.RELEASE. The gravatar module has no Robolectric and no returnDefaultValues, so those read as null in unit tests — it would have either put "null" in the string or forced new test infrastructure into a module that has none. X-Platform: Android already carries the platform. Happy to add it if you'd rather have it.
  • The Coil helper is duplicated in gravatar-ui and gravatar-quickeditor. internal doesn't cross a module boundary and the core module has no business depending on Coil, so the alternative was making it public API. Six lines each felt like the cheaper of the two.
  • appIdentifier has no @JvmOverloads, matching the existing apiKey and context, neither of which is annotated. Worth adding across all three if Java consumers matter, but that felt like a separate change.

🤖 Generated with Claude Code

SDK requests went out as `okhttp/5.4.0`, which makes them indistinguishable
from any other OkHttp client and leaves a server nothing to attribute a
request to. This surfaced when Automattic systems could not identify the
WordPress/Jetpack Android app's Gravatar traffic while investigating an edge
rule on api.gravatar.com.

`buildOkHttpClient` is the single funnel for every API client in the SDK, so
setting the header in `SdkVersionInterceptor` covers both the core services
and the Quick Editor's internal ones, with no change to any call path. It is
only set when absent, so a consumer that passes its own client keeps its own
User-Agent, and OkHttp's BridgeInterceptor then leaves the header alone.

`Gravatar.appIdentifier()` lets an app state the name it is already known by
server-side, which `context()` cannot do well: the launcher label is
localized and usually suffixed per build type, so one app reaches the
backend under several names. `context()` now also reads the package version.

The Quick Editor's image download uses DownloadManager rather than OkHttp,
so the interceptor cannot reach it and it sets the header itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wpmobilebot

wpmobilebot commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator
📲 You can test the changes from this Pull Request in Gravatar Demo by scanning the QR code below to install the corresponding build.
App NameGravatar Demo
Commit926c7ee
Direct Downloadgravatar-demo-prototype-build-pr735-926c7ee.apk

@adalpari adalpari added the enhancement Enhancement label Oct 19, 2026
@adalpari
adalpari marked this pull request as ready for review October 19, 2026 10:47
adalpari and others added 2 commits October 19, 2026 13:24
Two fixes from reviewing the User-Agent change.

Reject identifiers a header cannot carry. `appVersion` was interpolated
into the User-Agent raw, while `appName` one line above was trimmed and
whitespace-dashed. A version holding anything outside tab and printable
ASCII — `appIdentifier("wp-android", "1.0-β")`, or an `android:versionName`
that aapt2 happily accepts — made OkHttp reject the header from inside
the interceptor, failing every request with an opaque `ErrorType.Unknown`
and rethrowing on the dispatcher thread. `appIdentifier` now fails on the
call that supplied the bad value instead; whitespace stays legal, since a
header value may contain it. `context()` only drops an unusable
`versionName` and logs, rather than throwing: the caller did not choose
that string, and the name alone still identifies the app.

Make `appIdentifier` take precedence, as its KDoc already claimed. Both
it and `context()` were unconditional writes to the same fields, so the
order decided the winner, and the only documented initialization ends in
`.context(appContext)`. An app naming itself in `Application.onCreate`
silently reverted to the localized, build-type-suffixed launcher label
the next time anything called `context()` — exactly what the identifier
exists to avoid. `context()` now leaves an explicit identifier alone in
either order.

Also documents `appIdentifier` in the get-started guide, which only
showed `context()`, along with the "set it before the first request"
requirement that lived solely in the pull request description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composed User-Agent was only observable with a proxy, so there was
no way to check what an app actually sends short of intercepting its
traffic. `Gravatar.userAgent` is public for exactly this, so the demo now
displays it above the tabs, with a button for each way of naming the app.

Pressing `context()` after `appIdentifier()` leaves the header alone,
which makes the precedence rule visible in a running app rather than only
in a unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Logger.w(LOG_TAG, "Ignoring context(): appIdentifier() already named this app.")
return this
}
GravatarSdkContainer.instance.appName = getApplicationName(context)

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.

context() saves the launcher label as appName without checking it. appIdentifier() checks with isHeaderSafe, and so does the version path, but the name from context() is never checked.

If the label has non-ASCII characters (for example グラバタ or Café), OkHttp throws when it sets the header, so every SDK request fails. A non-breaking space (Jetpack\u00A0Debug) does the same, because the \s regex doesn't replace it.

This already happens on trunk through X-Source, but this PR adds User-Agent as a second way to hit it. The KDoc in GravatarSdkContainer also says context() drops unsafe values, but that's only true for the version.

Could we make the label header-safe before saving it, or skip it the same way we skip a bad versionName? I checked this with a unit test: context() with グラバタ and then a request through SdkVersionInterceptor throws IllegalArgumentException.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! I've aligned and set the same check and flow for both now: 5227b7b

@@ -24,6 +25,7 @@ internal class ImageDownloader(
.addRequestHeader("X-Platform", "Android")
.addRequestHeader("X-SDK-Version", BuildConfig.SDK_VERSION)
.addRequestHeader("X-Source", appName)

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.

This request now gets User-Agent from Gravatar.userAgent, which uses the name from appIdentifier(). But X-Source still uses context.appName, which is the launcher label. So one request can carry two different app names.

Example: the app calls appIdentifier("wp-android", "27.6"). API calls send X-Source: wp-android, but the download sends X-Source: Jetpack Debug together with User-Agent: wp-android/27.6 ….

Could we use the same name for both headers? One option is to expose the SDK's app name from gravatar and only fall back to the launcher label when it's not set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed! e2874f8

val userAgent: String
get() = buildString {
appName?.let { name ->
append(name.trim().replace(WHITESPACE, "-"))

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.

Two problems with how the app name goes into the User-Agent:

  1. Characters that aren't allowed. We only replace whitespace. Characters like /, (, ) and ; stay in, and they aren't allowed in a User-Agent product token. Example: a label A/B Tester with version 2.0 gives A/B-Tester/2.0 GravatarAndroid/…. A parser will read the product as A and the version as B-Tester/2.0, so server rules keyed on the app name won't match.

  2. Blank name. If the launcher label is empty or only spaces, the User-Agent becomes /27.6 GravatarAndroid/…, which has no app name before the slash. We also send an empty X-Source header. appIdentifier() already rejects a blank name, but context() doesn't.

Could we replace every character that isn't a valid token character, and treat a name that ends up empty as "no name"? Then the User-Agent would be just GravatarAndroid/… and no X-Source would be sent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is related to comment 1, so also fixed here: 5227b7b

// OkHttp's BridgeInterceptor runs after this one and also only fills a missing header,
// so setting it here is what stops the default `okhttp/<version>` from going out.
if (request.header(USER_AGENT) == null) {
header(USER_AGENT, GravatarSdkContainer.instance.userAgent)

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.

This sets the User-Agent only for the OkHttp clients the SDK builds for API calls. Images in gravatar-ui (Avatar, SocialMedia) and gravatar-quickeditor (SelectableAvatar, AltTextPage) load through Coil's default ImageLoader, which still sends okhttp/4.12.0.

So avatar and social icon requests to gravatar.com won't carry the new User-Agent, even though the PR description and docs say "every SDK request". Is that on purpose? If yes, could we update the docs to say API requests only? If not, we could pass an imageLoader that adds the header to those AsyncImage calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've opted to include the user-agent here too: 926c7ee

adalpari and others added 5 commits October 20, 2026 12:28
The app name reached the wire two different ways: the User-Agent replaced
whitespace in the launcher label, while X-Source sent the label verbatim. So
`A/B Tester` became the product `A` to any parser, a blank label produced a
leading `/27.6`, and a non-ASCII label made OkHttp throw from inside the
interceptor and fail every request the SDK made.

Reduce the label to an RFC 9110 product token once, on write, and send that
token in both headers. A label with nothing a token can hold leaves X-Source
off and the User-Agent as the SDK token alone, rather than sending something
unusable or nothing at all. `appIdentifier` now rejects a name that is not
already a token instead of quietly rewriting one the caller chose, suggesting
the token it would have made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ImageDownloader` read the launcher label straight off the `Context` for
`X-Source` while taking `User-Agent` from the SDK, so a download made with
`appIdentifier("wp-android", "27.6")` set went out as `X-Source: Jetpack Debug`
alongside `User-Agent: wp-android/27.6 …` — one request naming the app two
ways, and a label `DownloadManager` could not send would have broken the
download the way it used to break API calls.

Expose the name the SDK sends as `Gravatar.appName`, next to the User-Agent it
already exposes, and have the downloader ask for that instead of deriving a
second name of its own. The `Context.appName` extension stays for the OAuth
page, which wants the localized label precisely because it shows it to a person.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Avatars and social icons load through Coil, whose ImageLoader builds its own
OkHttp client that the SDK's interceptors never see. So the requests this SDK
makes most of — one per avatar rendered — were still reaching gravatar.com as
`okhttp/<version>`, the anonymous default the User-Agent exists to replace,
while the docs claimed every SDK request carried it.

Set the header on the ImageRequest instead of supplying an ImageLoader of our
own, so the host app's cache, interceptors and dispatchers are left alone and
the only thing the SDK adds is the name. The helper is duplicated in both UI
modules because `internal` does not cross a module boundary and the core module
has no business depending on Coil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `@throws` on `appIdentifier` only mentioned values a header cannot carry,
but the name is now held to the stricter bar of a product token, so `My App`
is rejected too — and nothing told a reader that, or that a launcher label
`context()` derives gets reduced before it is sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@adalpari

Copy link
Copy Markdown
Contributor Author

@AdamGrzybkowski I think the PR is ready for another go :)

@AdamGrzybkowski AdamGrzybkowski left a comment

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.

Thanks @adalpari, all four comments are fixed. 🚀

You can test this PR in the app using the snapshot CI published:

2.5.1-128-g8efa9e0a-SNAPSHOT

Add the snapshots repository to settings.gradle.kts:

maven {
    url = uri("https://central.sonatype.com/repository/maven-snapshots/")
    content { includeGroup("com.gravatar") }
}

@adalpari
adalpari merged commit 4c3b770 into trunk Oct 20, 2026
13 checks passed
@adalpari
adalpari deleted the add/sdk-user-agent branch October 20, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants