Skip to content

Commit 07db276

Browse files
aapelivclaude
andcommitted
Fix native app User-Agent reporting on iOS and Android
Native gRPC-web API requests went out with the platform HTTP stack's default User-Agent (okhttp's "okhttp/4.12.0" on Android, CFNetwork/Darwin on iOS) instead of a recognisable CouchersNative identifier. Add a UserAgentInterceptor that sets an explicit User-Agent on every unary API request. Also fix the "build unknown" segment: nativeBuildVersion is read from expo-application instead of the no-longer-populated Constants.nativeBuildVersion, and expo-application is promoted to a direct dependency. Fixes #8689 Fixes #8692 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0878305 commit 07db276

6 files changed

Lines changed: 47 additions & 4 deletions

File tree

app/mobile/jest.setup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ jest.mock("expo-constants", () => ({
1515
gitHash: "abc12345",
1616
},
1717
},
18+
}));
19+
20+
// Mock expo-application globally
21+
jest.mock("expo-application", () => ({
1822
nativeBuildVersion: "42",
1923
}));
2024

app/mobile/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@react-navigation/drawer": "^7.3.9",
3333
"@react-navigation/native": "^7.1.6",
3434
"expo": "~54.0.34",
35+
"expo-application": "~7.0.8",
3536
"expo-build-properties": "^1.0.10",
3637
"expo-constants": "~18.0.11",
3738
"expo-dev-client": "~6.0.21",

app/mobile/service/client.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AuthPromiseClient } from "@/proto/auth_grpc_web_pb";
33
import { NotificationsPromiseClient } from "@/proto/notifications_grpc_web_pb";
44

55
import isGrpcError from "@/service/utils/isGrpcError";
6+
import { applicationNameForUserAgent } from "@/utils/userAgent";
67

78
const URL =
89
process.env.NEXT_PUBLIC_API_BASE_URL ||
@@ -60,11 +61,26 @@ class TimeoutInterceptor {
6061
}
6162
}
6263

64+
// Sets an explicit User-Agent on API requests. Without this, native requests
65+
// go out with the platform HTTP stack's default UA (okhttp's "okhttp/4.12.0"
66+
// on Android, CFNetwork/Darwin on iOS), making API traffic indistinguishable
67+
// from generic clients.
68+
export class UserAgentInterceptor {
69+
async intercept(
70+
request: Request<unknown, unknown>,
71+
invoker: (request: unknown) => unknown,
72+
) {
73+
request.getMetadata()["User-Agent"] = applicationNameForUserAgent;
74+
return invoker(request);
75+
}
76+
}
77+
6378
const authInterceptor = new AuthInterceptor();
6479
const timeoutInterceptor = new TimeoutInterceptor();
80+
const userAgentInterceptor = new UserAgentInterceptor();
6581

6682
const opts = {
67-
unaryInterceptors: [authInterceptor, timeoutInterceptor],
83+
unaryInterceptors: [authInterceptor, timeoutInterceptor, userAgentInterceptor],
6884
// this modifies the behaviour on the API so that it will send cookies on the requests
6985
withCredentials: true,
7086
/// TODO: streaming interceptor for auth https://grpc.io/blog/grpc-web-interceptor/

app/mobile/tests/service/client.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { StatusCode } from "grpc-web";
1+
import { Request, StatusCode } from "grpc-web";
22

33
import {
44
AuthInterceptor,
55
setUnauthenticatedErrorHandler,
6+
UserAgentInterceptor,
67
} from "@/service/client";
78

89
describe("AuthInterceptor", () => {
@@ -37,3 +38,19 @@ describe("AuthInterceptor", () => {
3738
expect(errorHandler).not.toHaveBeenCalled();
3839
});
3940
});
41+
42+
describe("UserAgentInterceptor", () => {
43+
it("sets a CouchersNative User-Agent on the request metadata", async () => {
44+
const metadata: Record<string, string> = {};
45+
const request = {
46+
getMetadata: () => metadata,
47+
} as unknown as Request<unknown, unknown>;
48+
const invokerMock = jest.fn(() => ({ test: "test" }));
49+
const interceptor = new UserAgentInterceptor();
50+
51+
const response = await interceptor.intercept(request, invokerMock);
52+
53+
expect(response).toMatchObject({ test: "test" });
54+
expect(metadata["User-Agent"]).toContain("CouchersNative/");
55+
});
56+
});

app/mobile/utils/userAgent.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import * as Application from "expo-application";
12
import Constants from "expo-constants";
23
import { Platform } from "react-native";
34

45
const appVersion = Constants.expoConfig?.version ?? "unknown";
56
const gitHash = Constants.expoConfig?.extra?.gitHash ?? "unknown";
6-
const nativeVersion = Constants.nativeBuildVersion ?? "unknown";
7+
// Constants.nativeBuildVersion was removed in recent Expo SDKs; expo-application
8+
// reads the build number from the native build at runtime, so it works even
9+
// with EAS remote/auto-incremented version codes.
10+
const nativeBuildVersion = Application.nativeBuildVersion ?? "unknown";
711

8-
export const applicationNameForUserAgent = `CouchersNative/${appVersion} (${Platform.OS}; build ${nativeVersion}; ${gitHash})`;
12+
export const applicationNameForUserAgent = `CouchersNative/${appVersion} (${Platform.OS}; build ${nativeBuildVersion}; ${gitHash})`;

0 commit comments

Comments
 (0)