Skip to content

Commit 6e08702

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 fa47034 commit 6e08702

File tree

10 files changed

+359
-155
lines changed

10 files changed

+359
-155
lines changed

lib/api/core.dart

Lines changed: 10 additions & 10 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

@@ -37,11 +38,14 @@ class ApiConnection {
3738
String? email,
3839
String? apiKey,
3940
required http.Client client,
41+
Map<String, String>? userAgentHeader,
4042
}) : assert((email != null) == (apiKey != null)),
4143
_authValue = (email != null && apiKey != null)
4244
? _authHeaderValue(email: email, apiKey: apiKey)
4345
: null,
44-
_client = client;
46+
_client = client,
47+
_userAgentHeader =
48+
userAgentHeader ?? <String, String>{'User-Agent': 'ZulipFlutter'};
4549

4650
/// Construct an API connection that talks to a live Zulip server over the real network.
4751
ApiConnection.live({
@@ -51,7 +55,8 @@ class ApiConnection {
5155
String? apiKey,
5256
}) : this(client: http.Client(),
5357
realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel,
54-
email: email, apiKey: apiKey);
58+
email: email, apiKey: apiKey,
59+
userAgentHeader: ZulipBinding.instance.userAgentHeader());
5560

5661
final Uri realmUrl;
5762

@@ -69,6 +74,8 @@ class ApiConnection {
6974
/// * API docs at <https://zulip.com/api/changelog>.
7075
int? zulipFeatureLevel;
7176

77+
final Map<String, String> _userAgentHeader;
78+
7279
final String? _authValue;
7380

7481
void addAuth(http.BaseRequest request) {
@@ -88,7 +95,7 @@ class ApiConnection {
8895
assert(debugLog("${request.method} ${request.url}"));
8996

9097
addAuth(request);
91-
request.headers.addAll(userAgentHeader());
98+
request.headers.addAll(_userAgentHeader);
9299
if (overrideUserAgent != null) {
93100
request.headers['User-Agent'] = overrideUserAgent;
94101
}
@@ -213,13 +220,6 @@ Map<String, String> authHeader({required String email, required String apiKey})
213220
};
214221
}
215222

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

lib/model/binding.dart

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
77
import 'package:url_launcher/url_launcher.dart' as url_launcher;
88

99
import '../host/android_notifications.dart';
10+
import '../log.dart';
1011
import '../widgets/store.dart';
1112
import 'store.dart';
1213

@@ -131,6 +132,12 @@ abstract class ZulipBinding {
131132

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

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

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

149-
AndroidDeviceInfo({required this.sdkInt});
163+
AndroidDeviceInfo({
164+
required this.release,
165+
required this.sdkInt,
166+
});
150167
}
151168

152169
/// Like [device_info_plus.IosDeviceInfo], but without things we don't use.
@@ -159,6 +176,59 @@ class IosDeviceInfo extends BaseDeviceInfo {
159176
IosDeviceInfo({required this.systemVersion});
160177
}
161178

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

262+
// Stored user agent header, since it remains constant.
263+
Map<String, String>? _userAgentHeader;
264+
192265
@override
193266
BaseDeviceInfo? get deviceInfo => _deviceInfo;
194267
BaseDeviceInfo? _deviceInfo;
@@ -200,9 +273,16 @@ class LiveZulipBinding extends ZulipBinding {
200273
Future<void> _prefetchDeviceInfo() async {
201274
final info = await device_info_plus.DeviceInfoPlugin().deviceInfo;
202275
_deviceInfo = switch (info) {
203-
device_info_plus.AndroidDeviceInfo(:var version) => AndroidDeviceInfo(sdkInt: version.sdkInt),
204-
device_info_plus.IosDeviceInfo(:var systemVersion) => IosDeviceInfo(systemVersion: systemVersion),
205-
_ => throw UnimplementedError(),
276+
device_info_plus.AndroidDeviceInfo() => AndroidDeviceInfo(release: info.version.release,
277+
sdkInt: info.version.sdkInt),
278+
device_info_plus.IosDeviceInfo() => IosDeviceInfo(systemVersion: info.systemVersion),
279+
device_info_plus.MacOsDeviceInfo() => MacOsDeviceInfo(majorVersion: info.majorVersion,
280+
minorVersion: info.minorVersion,
281+
patchVersion: info.patchVersion),
282+
device_info_plus.WindowsDeviceInfo() => WindowsDeviceInfo(),
283+
device_info_plus.LinuxDeviceInfo() => LinuxDeviceInfo(name: info.name,
284+
versionId: info.versionId),
285+
_ => throw UnimplementedError(),
206286
};
207287
}
208288

@@ -266,4 +346,43 @@ class LiveZulipBinding extends ZulipBinding {
266346

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

lib/widgets/content.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1308,7 +1308,7 @@ class RealmContentNetworkImage extends StatelessWidget {
13081308
if (src.origin == account.realmUrl.origin) ...authHeader(
13091309
email: account.email, apiKey: account.apiKey,
13101310
),
1311-
...userAgentHeader(),
1311+
...ZulipBinding.instance.userAgentHeader(),
13121312
},
13131313
cacheWidth: cacheWidth,
13141314
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

0 commit comments

Comments
 (0)