Skip to content

Commit 7f2160c

Browse files
api: Embed platform and app info in user-agent
Generate the user-agent using `deviceInfo` and `packageInfo` from ZulipBinding. Fixes: #467
1 parent 7e11174 commit 7f2160c

File tree

4 files changed

+147
-13
lines changed

4 files changed

+147
-13
lines changed

lib/api/core.dart

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import 'dart:convert';
22
import 'dart:io';
33

4+
import 'package:flutter/foundation.dart';
45
import 'package:http/http.dart' as http;
56

67
import '../log.dart';
8+
import '../model/binding.dart';
79
import '../model/localizations.dart';
810
import 'exception.dart';
911

@@ -37,6 +39,7 @@ class ApiConnection {
3739
String? email,
3840
String? apiKey,
3941
required http.Client client,
42+
required this.useBinding,
4043
}) : assert((email != null) == (apiKey != null)),
4144
_authValue = (email != null && apiKey != null)
4245
? _authHeaderValue(email: email, apiKey: apiKey)
@@ -51,7 +54,7 @@ class ApiConnection {
5154
String? apiKey,
5255
}) : this(client: http.Client(),
5356
realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel,
54-
email: email, apiKey: apiKey);
57+
email: email, apiKey: apiKey, useBinding: true);
5558

5659
final Uri realmUrl;
5760

@@ -69,6 +72,36 @@ class ApiConnection {
6972
/// * API docs at <https://zulip.com/api/changelog>.
7073
int? zulipFeatureLevel;
7174

75+
/// Toggles the use of a user-agent generated via [ZulipBinding].
76+
///
77+
/// When set to true, the user-agent will be generated using
78+
/// [ZulipBinding.deviceInfo] and [ZulipBinding.packageInfo].
79+
/// Otherwise, a fallback user-agent [kFallbackUserAgentHeader] will be used.
80+
final bool useBinding;
81+
82+
Map<String, String>? _cachedUserAgentHeader;
83+
84+
void addUserAgent(http.BaseRequest request) {
85+
if (!useBinding) {
86+
request.headers.addAll(kFallbackUserAgentHeader);
87+
return;
88+
}
89+
90+
if (_cachedUserAgentHeader != null) {
91+
request.headers.addAll(_cachedUserAgentHeader!);
92+
return;
93+
}
94+
95+
final deviceInfo = ZulipBinding.instance.syncDeviceInfo;
96+
final packageInfo = ZulipBinding.instance.syncPackageInfo;
97+
if (deviceInfo == null || packageInfo == null) {
98+
request.headers.addAll(kFallbackUserAgentHeader);
99+
return;
100+
}
101+
_cachedUserAgentHeader = _buildUserAgentHeader(deviceInfo, packageInfo);
102+
request.headers.addAll(_cachedUserAgentHeader!);
103+
}
104+
72105
final String? _authValue;
73106

74107
void addAuth(http.BaseRequest request) {
@@ -88,9 +121,10 @@ class ApiConnection {
88121
assert(debugLog("${request.method} ${request.url}"));
89122

90123
addAuth(request);
91-
request.headers.addAll(userAgentHeader());
92124
if (overrideUserAgent != null) {
93125
request.headers['User-Agent'] = overrideUserAgent;
126+
} else {
127+
addUserAgent(request);
94128
}
95129

96130
final http.StreamedResponse response;
@@ -213,10 +247,47 @@ Map<String, String> authHeader({required String email, required String apiKey})
213247
};
214248
}
215249

250+
/// Fallback user-agent header.
251+
///
252+
/// See documentation on [ApiConnection.useBinding].
253+
@visibleForTesting
254+
const kFallbackUserAgentHeader = {'User-Agent': 'ZulipFlutter'};
255+
216256
Map<String, String> userAgentHeader() {
257+
final deviceInfo = ZulipBinding.instance.syncDeviceInfo;
258+
final packageInfo = ZulipBinding.instance.syncPackageInfo;
259+
if (deviceInfo == null || packageInfo == null) {
260+
return kFallbackUserAgentHeader;
261+
}
262+
return _buildUserAgentHeader(deviceInfo, packageInfo);
263+
}
264+
265+
Map<String, String> _buildUserAgentHeader(BaseDeviceInfo deviceInfo, PackageInfo packageInfo) {
266+
final osInfo = switch (deviceInfo) {
267+
AndroidDeviceInfo(
268+
:var release) => 'Android $release', // "Android 14"
269+
IosDeviceInfo(
270+
:var systemVersion) => 'iOS $systemVersion', // "iOS 17.4"
271+
MacOsDeviceInfo(
272+
:var majorVersion,
273+
:var minorVersion,
274+
:var patchVersion) => 'macOS $majorVersion.$minorVersion.$patchVersion', // "macOS 14.5.0"
275+
WindowsDeviceInfo() => 'Windows', // "Windows"
276+
LinuxDeviceInfo(
277+
:var name,
278+
:var versionId) => 'Linux; $name${versionId != null ? ' $versionId' : ''}', // "Linux; Fedora Linux 40" or "Linux; Fedora Linux"
279+
_ => throw UnimplementedError(),
280+
};
281+
final PackageInfo(:version, :buildNumber) = packageInfo;
282+
283+
// Possible examples:
284+
// 'ZulipFlutter/0.0.15+15 (Android 14)'
285+
// 'ZulipFlutter/0.0.15+15 (iOS 17.4)'
286+
// 'ZulipFlutter/0.0.15+15 (macOS 14.5.0)'
287+
// 'ZulipFlutter/0.0.15+15 (Windows)'
288+
// 'ZulipFlutter/0.0.15+15 (Linux; Fedora Linux 40)'
217289
return {
218-
// TODO(#467) include platform, platform version, and app version
219-
'User-Agent': 'ZulipFlutter',
290+
'User-Agent': 'ZulipFlutter/$version+$buildNumber ($osInfo)',
220291
};
221292
}
222293

test/api/core_test.dart

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ import 'package:http/http.dart' as http;
66
import 'package:test/scaffolding.dart';
77
import 'package:zulip/api/core.dart';
88
import 'package:zulip/api/exception.dart';
9+
import 'package:zulip/model/binding.dart';
910
import 'package:zulip/model/localizations.dart';
1011

12+
import '../model/binding.dart';
1113
import '../stdlib_checks.dart';
1214
import 'exception_checks.dart';
1315
import 'fake_api.dart';
1416
import '../example_data.dart' as eg;
1517

1618
void main() {
19+
TestZulipBinding.ensureInitialized();
20+
1721
test('ApiConnection.get', () async {
1822
Future<void> checkRequest(Map<String, dynamic>? params, String expectedRelativeUrl) {
1923
return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {
@@ -24,7 +28,7 @@ void main() {
2428
..url.asString.equals('${eg.realmUrl.origin}$expectedRelativeUrl')
2529
..headers.deepEquals({
2630
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
27-
...userAgentHeader(),
31+
...kFallbackUserAgentHeader,
2832
})
2933
..body.equals('');
3034
});
@@ -55,7 +59,7 @@ void main() {
5559
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
5660
..headers.deepEquals({
5761
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
58-
...userAgentHeader(),
62+
...kFallbackUserAgentHeader,
5963
if (expectContentType)
6064
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
6165
})
@@ -88,7 +92,7 @@ void main() {
8892
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
8993
..headers.deepEquals({
9094
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
91-
...userAgentHeader(),
95+
...kFallbackUserAgentHeader,
9296
})
9397
..fields.deepEquals({})
9498
..files.single.which((it) => it
@@ -121,7 +125,7 @@ void main() {
121125
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
122126
..headers.deepEquals({
123127
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
124-
...userAgentHeader(),
128+
...kFallbackUserAgentHeader,
125129
if (expectContentType)
126130
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
127131
})
@@ -308,6 +312,57 @@ void main() {
308312
check(st.toString()).contains("distinctivelyNamedFromJson");
309313
}
310314
});
315+
316+
group('ApiConnection user-agent', () {
317+
Future<void> checkUserAgent(String expectedUserAgent) async {
318+
return FakeApiConnection.with_(account: eg.selfAccount, useBinding: true,
319+
(connection) async {
320+
connection.prepare(json: {});
321+
await connection.get(kExampleRouteName, (json) => json, 'example/route', null);
322+
check(connection.lastRequest!).isA<http.Request>()
323+
.headers['User-Agent'].equals(expectedUserAgent);
324+
325+
connection.prepare(json: {});
326+
await connection.post(kExampleRouteName, (json) => json, 'example/route', null);
327+
check(connection.lastRequest!).isA<http.Request>()
328+
.headers['User-Agent'].equals(expectedUserAgent);
329+
330+
connection.prepare(json: {});
331+
await connection.postFileFromStream(
332+
kExampleRouteName,
333+
(json) => json, 'example/route',
334+
Stream.value([1]), 1,
335+
);
336+
check(connection.lastRequest!).isA<http.MultipartRequest>()
337+
.headers['User-Agent'].equals(expectedUserAgent);
338+
339+
connection.prepare(json: {});
340+
await connection.delete(kExampleRouteName, (json) => json, 'example/route', null);
341+
check(connection.lastRequest!).isA<http.Request>()
342+
.headers['User-Agent'].equals(expectedUserAgent);
343+
});
344+
}
345+
346+
const packageInfo = PackageInfo(version: '0.0.1', buildNumber: '1');
347+
348+
const testCases = [
349+
('ZulipFlutter/0.0.1+1 (Android 14)', AndroidDeviceInfo(release: '14', sdkInt: 34), ),
350+
('ZulipFlutter/0.0.1+1 (iOS 17.4)', IosDeviceInfo(systemVersion: '17.4'), ),
351+
('ZulipFlutter/0.0.1+1 (macOS 14.5.0)', MacOsDeviceInfo(majorVersion: 14, minorVersion: 5, patchVersion: 0)),
352+
('ZulipFlutter/0.0.1+1 (Windows)', WindowsDeviceInfo(), ),
353+
('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux 40)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: '40'), ),
354+
('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: null), ),
355+
];
356+
357+
for (final (userAgent, deviceInfo) in testCases) {
358+
test('matches $userAgent', () async {
359+
testBinding.deviceInfoResult = deviceInfo;
360+
testBinding.packageInfoResult = packageInfo;
361+
addTearDown(testBinding.reset);
362+
await checkUserAgent(userAgent);
363+
});
364+
}
365+
});
311366
}
312367

313368
class DistinctiveError extends Error {

test/api/fake_api.dart

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,27 +134,31 @@ class FakeApiConnection extends ApiConnection {
134134
int? zulipFeatureLevel = eg.futureZulipFeatureLevel,
135135
String? email,
136136
String? apiKey,
137+
bool useBinding = false,
137138
}) : this._(
138139
realmUrl: realmUrl ?? eg.realmUrl,
139140
zulipFeatureLevel: zulipFeatureLevel,
140141
email: email,
141142
apiKey: apiKey,
142143
client: FakeHttpClient(),
144+
useBinding: useBinding,
143145
);
144146

145-
FakeApiConnection.fromAccount(Account account)
147+
FakeApiConnection.fromAccount(Account account, {required bool useBinding})
146148
: this(
147149
realmUrl: account.realmUrl,
148150
zulipFeatureLevel: account.zulipFeatureLevel,
149151
email: account.email,
150-
apiKey: account.apiKey);
152+
apiKey: account.apiKey,
153+
useBinding: useBinding);
151154

152155
FakeApiConnection._({
153156
required super.realmUrl,
154157
required super.zulipFeatureLevel,
155158
super.email,
156159
super.apiKey,
157160
required this.client,
161+
required super.useBinding,
158162
}) : super(client: client);
159163

160164
final FakeHttpClient client;
@@ -171,12 +175,16 @@ class FakeApiConnection extends ApiConnection {
171175
Uri? realmUrl,
172176
int? zulipFeatureLevel = eg.futureZulipFeatureLevel,
173177
Account? account,
178+
bool useBinding = false,
174179
}) async {
175180
assert((account == null)
176181
|| (realmUrl == null && zulipFeatureLevel == eg.futureZulipFeatureLevel));
177182
final connection = (account != null)
178-
? FakeApiConnection.fromAccount(account)
179-
: FakeApiConnection(realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel);
183+
? FakeApiConnection.fromAccount(account, useBinding: useBinding)
184+
: FakeApiConnection(
185+
realmUrl: realmUrl,
186+
zulipFeatureLevel: zulipFeatureLevel,
187+
useBinding: useBinding);
180188
try {
181189
return await fn(connection);
182190
} finally {

test/api/route/messages_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ void main() {
316316
..method.equals('POST')
317317
..url.path.equals('/api/v1/messages')
318318
..bodyFields.deepEquals(expectedBodyFields)
319-
..headers['User-Agent'].equals(expectedUserAgent ?? userAgentHeader()['User-Agent']!);
319+
..headers['User-Agent'].equals(expectedUserAgent ?? kFallbackUserAgentHeader['User-Agent']!);
320320
}
321321

322322
test('smoke', () {

0 commit comments

Comments
 (0)