Skip to content

Commit 8458b72

Browse files
api: Embed platform and app info in user-agent
Generate the user-agent using `deviceInfo` and `packageInfo` from ZulipBinding, and since it now depends on them, move the function to the ZulipBinding class — making it explicit to the caller that it depends on ZulipBinding being initialised. In addition to that, support fetching macOS, Windows and Linux device information, so that user-agents for them can be generated. Fixes: #467
1 parent 06e6e65 commit 8458b72

13 files changed

+202
-53
lines changed

lib/api/core.dart

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:io';
44
import 'package:http/http.dart' as http;
55

66
import '../log.dart';
7+
import '../model/binding.dart';
78
import '../model/localizations.dart';
89
import 'exception.dart';
910

@@ -88,7 +89,7 @@ class ApiConnection {
8889
assert(debugLog("${request.method} ${request.url}"));
8990

9091
addAuth(request);
91-
request.headers.addAll(userAgentHeader());
92+
request.headers.addAll(ZulipBinding.instance.userAgentHeader());
9293
if (overrideUserAgent != null) {
9394
request.headers['User-Agent'] = overrideUserAgent;
9495
}
@@ -213,13 +214,6 @@ Map<String, String> authHeader({required String email, required String apiKey})
213214
};
214215
}
215216

216-
Map<String, String> userAgentHeader() {
217-
return {
218-
// TODO(#467) include platform, platform version, and app version
219-
'User-Agent': 'ZulipFlutter',
220-
};
221-
}
222-
223217
Map<String, String>? encodeParameters(Map<String, dynamic>? params) {
224218
return params?.map((k, v) =>
225219
MapEntry(k, v is RawParameter ? v.value : jsonEncode(v)));

lib/model/binding.dart

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ abstract class ZulipBinding {
131131

132132
/// Wraps the [AndroidNotificationHostApi] constructor.
133133
AndroidNotificationHostApi get androidNotificationHost;
134+
135+
/// Generates a user agent header for HTTP requests.
136+
///
137+
/// Uses [deviceInfo] to get operating system information
138+
/// and [packageInfo] to get application version information.
139+
Map<String, String> userAgentHeader();
134140
}
135141

136142
/// Like [device_info_plus.BaseDeviceInfo], but without things we don't use.
@@ -140,13 +146,23 @@ abstract class BaseDeviceInfo {
140146

141147
/// Like [device_info_plus.AndroidDeviceInfo], but without things we don't use.
142148
class AndroidDeviceInfo extends BaseDeviceInfo {
149+
/// The user-visible version string.
150+
///
151+
/// E.g., "1.0" or "3.4b5" or "bananas". This field is an opaque string.
152+
/// Do not assume that its value has any particular structure or that
153+
/// values of RELEASE from different releases can be somehow ordered.
154+
final String release;
155+
143156
/// The Android SDK version.
144157
///
145158
/// Possible values are defined in:
146159
/// https://developer.android.com/reference/android/os/Build.VERSION_CODES.html
147160
final int sdkInt;
148161

149-
AndroidDeviceInfo({required this.sdkInt});
162+
AndroidDeviceInfo({
163+
required this.release,
164+
required this.sdkInt,
165+
});
150166
}
151167

152168
/// Like [device_info_plus.IosDeviceInfo], but without things we don't use.
@@ -159,6 +175,59 @@ class IosDeviceInfo extends BaseDeviceInfo {
159175
IosDeviceInfo({required this.systemVersion});
160176
}
161177

178+
/// Like [device_info_plus.MacOsDeviceInfo], but without things we don't use.
179+
class MacOsDeviceInfo extends BaseDeviceInfo {
180+
/// The major release number, such as 10 in version 10.9.3.
181+
final int majorVersion;
182+
183+
/// The minor release number, such as 9 in version 10.9.3.
184+
final int minorVersion;
185+
186+
/// The update release number, such as 3 in version 10.9.3.
187+
final int patchVersion;
188+
189+
MacOsDeviceInfo({
190+
required this.majorVersion,
191+
required this.minorVersion,
192+
required this.patchVersion,
193+
});
194+
}
195+
196+
/// Like [device_info_plus.WindowsDeviceInfo], currently only used to
197+
/// determine if we're on Windows.
198+
class WindowsDeviceInfo implements BaseDeviceInfo {}
199+
200+
/// Like [device_info_plus.LinuxDeviceInfo], but without things we don't use.
201+
///
202+
/// See:
203+
/// https://www.freedesktop.org/software/systemd/man/os-release.html
204+
class LinuxDeviceInfo implements BaseDeviceInfo {
205+
/// A string identifying the operating system, without a version component,
206+
/// and suitable for presentation to the user.
207+
///
208+
/// Examples: 'Fedora', 'Debian GNU/Linux'.
209+
///
210+
/// If not set, defaults to 'Linux'.
211+
final String name;
212+
213+
/// A lower-case string identifying the operating system version, excluding
214+
/// any OS name information or release code name, and suitable for processing
215+
/// by scripts or usage in generated filenames.
216+
///
217+
/// The version is mostly numeric, and contains no spaces or other characters
218+
/// outside of 0–9, a–z, '.', '_' and '-'.
219+
///
220+
/// Examples: '17', '11.04'.
221+
///
222+
/// This field is optional and may be null on some systems.
223+
final String? versionId;
224+
225+
LinuxDeviceInfo({
226+
required this.name,
227+
required this.versionId,
228+
});
229+
}
230+
162231
/// Like [package_info_plus.PackageInfo], but without things we don't use.
163232
class PackageInfo {
164233
final String version;
@@ -191,6 +260,9 @@ class LiveZulipBinding extends ZulipBinding {
191260
return ZulipBinding.instance as LiveZulipBinding;
192261
}
193262

263+
// Stored user agent header, since it remains constant.
264+
Map<String, String>? _userAgentHeader;
265+
194266
@override
195267
BaseDeviceInfo get deviceInfo => _deviceInfo!;
196268
BaseDeviceInfo? _deviceInfo;
@@ -202,9 +274,16 @@ class LiveZulipBinding extends ZulipBinding {
202274
Future<void> _prefetchDeviceInfo() async {
203275
final info = await device_info_plus.DeviceInfoPlugin().deviceInfo;
204276
_deviceInfo = switch (info) {
205-
device_info_plus.AndroidDeviceInfo(:var version) => AndroidDeviceInfo(sdkInt: version.sdkInt),
206-
device_info_plus.IosDeviceInfo(:var systemVersion) => IosDeviceInfo(systemVersion: systemVersion),
207-
_ => throw UnimplementedError(),
277+
device_info_plus.AndroidDeviceInfo() => AndroidDeviceInfo(release: info.version.release,
278+
sdkInt: info.version.sdkInt),
279+
device_info_plus.IosDeviceInfo() => IosDeviceInfo(systemVersion: info.systemVersion),
280+
device_info_plus.MacOsDeviceInfo() => MacOsDeviceInfo(majorVersion: info.majorVersion,
281+
minorVersion: info.minorVersion,
282+
patchVersion: info.patchVersion),
283+
device_info_plus.WindowsDeviceInfo() => WindowsDeviceInfo(),
284+
device_info_plus.LinuxDeviceInfo() => LinuxDeviceInfo(name: info.name,
285+
versionId: info.versionId),
286+
_ => throw UnimplementedError(),
208287
};
209288
}
210289

@@ -268,4 +347,39 @@ class LiveZulipBinding extends ZulipBinding {
268347

269348
@override
270349
AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi();
350+
351+
@override
352+
Map<String, String> userAgentHeader() {
353+
return _userAgentHeader ??= buildUserAgentHeader(deviceInfo, packageInfo);
354+
}
355+
}
356+
357+
@visibleForTesting
358+
Map<String, String> buildUserAgentHeader(BaseDeviceInfo deviceInfo, PackageInfo packageInfo) {
359+
final osInfo = switch (deviceInfo) {
360+
AndroidDeviceInfo(
361+
:var release) => 'Android $release', // "Android 14"
362+
IosDeviceInfo(
363+
:var systemVersion) => 'iOS $systemVersion', // "iOS 17.4"
364+
MacOsDeviceInfo(
365+
:var majorVersion,
366+
:var minorVersion,
367+
:var patchVersion) => 'macOS $majorVersion.$minorVersion.$patchVersion', // "macOS 14.5.0"
368+
WindowsDeviceInfo() => 'Windows', // "Windows"
369+
LinuxDeviceInfo(
370+
:var name,
371+
:var versionId) => 'Linux; $name${versionId != null ? ' $versionId' : ''}', // "Linux; Fedora Linux 40" or "Linux; Fedora Linux"
372+
_ => throw UnimplementedError(),
373+
};
374+
final PackageInfo(:version, :buildNumber) = packageInfo;
375+
376+
// Possible examples:
377+
// 'ZulipFlutter/0.0.15+15 (Android 14)'
378+
// 'ZulipFlutter/0.0.15+15 (iOS 17.4)'
379+
// 'ZulipFlutter/0.0.15+15 (macOS 14.5.0)'
380+
// 'ZulipFlutter/0.0.15+15 (Windows)'
381+
// 'ZulipFlutter/0.0.15+15 (Linux; Fedora Linux 40)'
382+
return {
383+
'User-Agent': 'ZulipFlutter/$version+$buildNumber ($osInfo)',
384+
};
271385
}

lib/widgets/content.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,7 @@ class RealmContentNetworkImage extends StatelessWidget {
12351235
if (src.origin == account.realmUrl.origin) ...authHeader(
12361236
email: account.email, apiKey: account.apiKey,
12371237
),
1238-
...userAgentHeader(),
1238+
...ZulipBinding.instance.userAgentHeader(),
12391239
},
12401240
cacheWidth: cacheWidth,
12411241
cacheHeight: cacheHeight,

lib/widgets/lightbox.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:video_player/video_player.dart';
77
import '../api/core.dart';
88
import '../api/model/model.dart';
99
import '../log.dart';
10+
import '../model/binding.dart';
1011
import 'content.dart';
1112
import 'dialog.dart';
1213
import 'page.dart';
@@ -389,7 +390,7 @@ class _VideoLightboxPageState extends State<VideoLightboxPage> with PerAccountSt
389390
email: store.account.email,
390391
apiKey: store.account.apiKey,
391392
),
392-
...userAgentHeader()
393+
...ZulipBinding.instance.userAgentHeader(),
393394
});
394395
_controller!.addListener(_handleVideoControllerUpdate);
395396

test/api/core_test.dart

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@ 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+
tearDown(testBinding.reset);
21+
1722
test('ApiConnection.get', () async {
1823
Future<void> checkRequest(Map<String, dynamic>? params, String expectedRelativeUrl) {
1924
return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {
@@ -24,7 +29,7 @@ void main() {
2429
..url.asString.equals('${eg.realmUrl.origin}$expectedRelativeUrl')
2530
..headers.deepEquals({
2631
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
27-
...userAgentHeader(),
32+
...testBinding.userAgentHeader(),
2833
})
2934
..body.equals('');
3035
});
@@ -55,7 +60,7 @@ void main() {
5560
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
5661
..headers.deepEquals({
5762
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
58-
...userAgentHeader(),
63+
...testBinding.userAgentHeader(),
5964
if (expectContentType)
6065
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
6166
})
@@ -88,7 +93,7 @@ void main() {
8893
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
8994
..headers.deepEquals({
9095
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
91-
...userAgentHeader(),
96+
...testBinding.userAgentHeader(),
9297
})
9398
..fields.deepEquals({})
9499
..files.single.which((it) => it
@@ -121,7 +126,7 @@ void main() {
121126
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
122127
..headers.deepEquals({
123128
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
124-
...userAgentHeader(),
129+
...testBinding.userAgentHeader(),
125130
if (expectContentType)
126131
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
127132
})
@@ -308,6 +313,39 @@ void main() {
308313
check(st.toString()).contains("distinctivelyNamedFromJson");
309314
}
310315
});
316+
317+
group('ApiConnection user-agent', () {
318+
Future<void> checkUserAgent(String expectedUserAgent) async {
319+
return FakeApiConnection.with_(account: eg.selfAccount, (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.deepEquals({
324+
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
325+
...{'User-Agent': expectedUserAgent},
326+
});
327+
});
328+
}
329+
330+
final packageInfo = PackageInfo(version: '0.0.1', buildNumber: '1');
331+
332+
final testCases = [
333+
('ZulipFlutter/0.0.1+1 (Android 14)', AndroidDeviceInfo(release: '14', sdkInt: 34), packageInfo),
334+
('ZulipFlutter/0.0.1+1 (iOS 17.4)', IosDeviceInfo(systemVersion: '17.4'), packageInfo),
335+
('ZulipFlutter/0.0.1+1 (macOS 14.5.0)', MacOsDeviceInfo(majorVersion: 14, minorVersion: 5, patchVersion: 0), packageInfo),
336+
('ZulipFlutter/0.0.1+1 (Windows)', WindowsDeviceInfo(), packageInfo),
337+
('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux 40)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: '40'), packageInfo),
338+
('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: null), packageInfo),
339+
];
340+
341+
for (final (userAgent, deviceInfo, pacakgeInfo) in testCases) {
342+
test('matches $userAgent', () async {
343+
testBinding.deviceInfoResult = deviceInfo;
344+
testBinding.packageInfoResult = pacakgeInfo;
345+
await checkUserAgent(userAgent);
346+
});
347+
}
348+
});
311349
}
312350

313351
class DistinctiveError extends Error {

test/api/fake_api_test.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import 'package:checks/checks.dart';
22
import 'package:test/scaffolding.dart';
33
import 'package:zulip/api/exception.dart';
44

5+
import '../model/binding.dart';
56
import 'exception_checks.dart';
67
import 'fake_api.dart';
78

89
void main() {
10+
TestZulipBinding.ensureInitialized();
11+
tearDown(testBinding.reset);
12+
913
test('baseline happy case', () async {
1014
final connection = FakeApiConnection();
1115
connection.prepare(json: {'a': 3});

test/api/route/messages_test.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@ import 'dart:convert';
33
import 'package:checks/checks.dart';
44
import 'package:http/http.dart' as http;
55
import 'package:test/scaffolding.dart';
6-
import 'package:zulip/api/core.dart';
76
import 'package:zulip/api/model/model.dart';
87
import 'package:zulip/api/model/narrow.dart';
98
import 'package:zulip/api/route/messages.dart';
109
import 'package:zulip/model/narrow.dart';
1110

1211
import '../../example_data.dart' as eg;
12+
import '../../model/binding.dart';
1313
import '../../stdlib_checks.dart';
1414
import '../fake_api.dart';
1515
import 'route_checks.dart';
1616

1717
void main() {
18+
TestZulipBinding.ensureInitialized();
19+
tearDown(testBinding.reset);
20+
1821
group('getMessageCompat', () {
1922
Future<Message?> checkGetMessageCompat(FakeApiConnection connection, {
2023
required bool expectLegacy,
@@ -316,7 +319,7 @@ void main() {
316319
..method.equals('POST')
317320
..url.path.equals('/api/v1/messages')
318321
..bodyFields.deepEquals(expectedBodyFields)
319-
..headers['User-Agent'].equals(expectedUserAgent ?? userAgentHeader()['User-Agent']!);
322+
..headers['User-Agent'].equals(expectedUserAgent ?? testBinding.userAgentHeader()['User-Agent']!);
320323
}
321324

322325
test('smoke', () {

test/api/route/notifications_test.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import 'package:http/http.dart' as http;
33
import 'package:test/scaffolding.dart';
44
import 'package:zulip/api/route/notifications.dart';
55

6+
import '../../model/binding.dart';
67
import '../../stdlib_checks.dart';
78
import '../fake_api.dart';
89

910
void main() {
11+
TestZulipBinding.ensureInitialized();
12+
tearDown(testBinding.reset);
13+
1014
group('registerFcmToken', () {
1115
Future<void> checkRegisterFcmToken(FakeApiConnection connection, {
1216
required String token,

0 commit comments

Comments
 (0)