Skip to content

Commit d94d4ed

Browse files
api: Embed platform and app info in user-agent
1 parent 6c949ea commit d94d4ed

13 files changed

+164
-48
lines changed

lib/api/core.dart

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

@@ -214,9 +215,33 @@ Map<String, String> authHeader({required String email, required String apiKey})
214215
}
215216

216217
Map<String, String> userAgentHeader() {
218+
final osInfo = switch (ZulipBinding.instance.deviceInfo) {
219+
AndroidDeviceInfo(
220+
:var release) => 'Android $release', // "Android 14"
221+
IosDeviceInfo(
222+
:var systemVersion) => 'iOS $systemVersion', // "iOS 17.4"
223+
MacOsDeviceInfo(
224+
:var majorVersion,
225+
:var minorVersion,
226+
:var patchVersion) => 'macOS $majorVersion.$minorVersion.$patchVersion', // "macOS 14.5.0"
227+
WindowsDeviceInfo() => 'Windows', // "Windows"
228+
LinuxDeviceInfo(
229+
:var name,
230+
:var versionId) => 'Linux; $name${versionId != null ? ' $versionId' : ''}', // "Linux; Fedora Linux 40" or "Linux; Fedora Linux"
231+
_ => throw UnimplementedError(),
232+
};
233+
final PackageInfo(
234+
version: appVersion,
235+
buildNumber: appBuildNumber) = ZulipBinding.instance.packageInfo;
236+
217237
return {
218-
// TODO(#467) include platform, platform version, and app version
219-
'User-Agent': 'ZulipFlutter',
238+
// Possible examples:
239+
// 'ZulipFlutter/0.0.15+15 (Android 14)'
240+
// 'ZulipFlutter/0.0.15+15 (iOS 17.4)'
241+
// 'ZulipFlutter/0.0.15+15 (macOS 14.5.0)'
242+
// 'ZulipFlutter/0.0.15+15 (Windows)'
243+
// 'ZulipFlutter/0.0.15+15 (Linux; Fedora Linux 40)'
244+
'User-Agent': 'ZulipFlutter/$appVersion+$appBuildNumber ($osInfo)',
220245
};
221246
}
222247

lib/main.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import 'model/binding.dart';
77
import 'notifications/receive.dart';
88
import 'widgets/app.dart';
99

10-
void main() {
10+
Future<void> main() async {
1111
assert(() {
1212
debugLogEnabled = true;
1313
return true;
1414
}());
1515
LicenseRegistry.addLicense(additionalLicenses);
16-
LiveZulipBinding.ensureInitialized();
1716
WidgetsFlutterBinding.ensureInitialized();
17+
await LiveZulipBinding.ensureInitialized();
1818
NotificationService.instance.start();
1919
runApp(const ZulipApp());
2020
}

lib/model/binding.dart

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:firebase_core/firebase_core.dart' as firebase_core;
33
import 'package:firebase_messaging/firebase_messaging.dart' as firebase_messaging;
44
import 'package:flutter/foundation.dart';
55
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
6+
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
67
import 'package:url_launcher/url_launcher.dart' as url_launcher;
78

89
import '../host/android_notifications.dart';
@@ -66,6 +67,18 @@ abstract class ZulipBinding {
6667
_instance = this;
6768
}
6869

70+
/// Provides device and operating system information,
71+
/// via package:device_info_plus.
72+
///
73+
/// This wraps [device_info_plus.DeviceInfoPlugin.deviceInfo].
74+
BaseDeviceInfo get deviceInfo;
75+
76+
/// Provides application package information,
77+
/// via package:package_info_plus.
78+
///
79+
/// This wraps [package_info_plus.PackageInfo.fromPlatform].
80+
PackageInfo get packageInfo;
81+
6982
/// Prepare the app's [GlobalStore], loading the necessary data.
7083
///
7184
/// Generally the app should call this function only once.
@@ -98,12 +111,6 @@ abstract class ZulipBinding {
98111
/// This wraps [url_launcher.closeInAppWebView].
99112
Future<void> closeInAppWebView();
100113

101-
/// Provides device and operating system information,
102-
/// via package:device_info_plus.
103-
///
104-
/// This wraps [device_info_plus.DeviceInfoPlugin.deviceInfo].
105-
Future<BaseDeviceInfo> deviceInfo();
106-
107114
/// Initialize Firebase, to use for notifications.
108115
///
109116
/// This wraps [firebase_core.Firebase.initializeApp].
@@ -133,13 +140,23 @@ abstract class BaseDeviceInfo {
133140

134141
/// Like [device_info_plus.AndroidDeviceInfo], but without things we don't use.
135142
class AndroidDeviceInfo extends BaseDeviceInfo {
143+
/// The user-visible version string.
144+
///
145+
/// E.g., "1.0" or "3.4b5" or "bananas". This field is an opaque string.
146+
/// Do not assume that its value has any particular structure or that
147+
/// values of RELEASE from different releases can be somehow ordered.
148+
final String release;
149+
136150
/// The Android SDK version.
137151
///
138152
/// Possible values are defined in:
139153
/// https://developer.android.com/reference/android/os/Build.VERSION_CODES.html
140154
final int sdkInt;
141155

142-
AndroidDeviceInfo({required this.sdkInt});
156+
AndroidDeviceInfo({
157+
required this.release,
158+
required this.sdkInt,
159+
});
143160
}
144161

145162
/// Like [device_info_plus.IosDeviceInfo], but without things we don't use.
@@ -152,6 +169,41 @@ class IosDeviceInfo extends BaseDeviceInfo {
152169
IosDeviceInfo({required this.systemVersion});
153170
}
154171

172+
class MacOsDeviceInfo extends BaseDeviceInfo {
173+
final int majorVersion;
174+
final int minorVersion;
175+
final int patchVersion;
176+
177+
MacOsDeviceInfo({
178+
required this.majorVersion,
179+
required this.minorVersion,
180+
required this.patchVersion,
181+
});
182+
}
183+
184+
class WindowsDeviceInfo implements BaseDeviceInfo {}
185+
186+
class LinuxDeviceInfo implements BaseDeviceInfo {
187+
final String name;
188+
final String? versionId;
189+
190+
LinuxDeviceInfo({
191+
required this.name,
192+
required this.versionId,
193+
});
194+
}
195+
196+
/// Like [package_info_plus.PackageInfo], but without things we don't use.
197+
class PackageInfo {
198+
final String version;
199+
final String buildNumber;
200+
201+
PackageInfo({
202+
required this.version,
203+
required this.buildNumber,
204+
});
205+
}
206+
155207
/// A concrete binding for use in the live application.
156208
///
157209
/// The global store returned by [loadGlobalStore], and consequently by
@@ -162,13 +214,49 @@ class IosDeviceInfo extends BaseDeviceInfo {
162214
/// underlying plugin method.
163215
class LiveZulipBinding extends ZulipBinding {
164216
/// Initialize the binding if necessary, and ensure it is a [LiveZulipBinding].
165-
static LiveZulipBinding ensureInitialized() {
217+
static Future<LiveZulipBinding> ensureInitialized() async {
166218
if (ZulipBinding._instance == null) {
167-
LiveZulipBinding();
219+
final binding = LiveZulipBinding();
220+
await Future.wait(<Future<void>>[
221+
binding._prefetchDeviceInfo(),
222+
binding._prefetchPackageInfo(),
223+
]);
168224
}
169225
return ZulipBinding.instance as LiveZulipBinding;
170226
}
171227

228+
@override
229+
BaseDeviceInfo get deviceInfo => _deviceInfo!;
230+
BaseDeviceInfo? _deviceInfo;
231+
232+
@override
233+
PackageInfo get packageInfo => _packageInfo!;
234+
PackageInfo? _packageInfo;
235+
236+
Future<void> _prefetchDeviceInfo() async {
237+
final info = await device_info_plus.DeviceInfoPlugin().deviceInfo;
238+
_deviceInfo = switch (info) {
239+
device_info_plus.AndroidDeviceInfo() => AndroidDeviceInfo(release: info.version.release,
240+
sdkInt: info.version.sdkInt),
241+
device_info_plus.IosDeviceInfo() => IosDeviceInfo(systemVersion: info.systemVersion),
242+
device_info_plus.MacOsDeviceInfo() => MacOsDeviceInfo(majorVersion: info.majorVersion,
243+
minorVersion: info.minorVersion,
244+
patchVersion: info.patchVersion),
245+
device_info_plus.WindowsDeviceInfo() => WindowsDeviceInfo(),
246+
device_info_plus.LinuxDeviceInfo() => LinuxDeviceInfo(name: info.name,
247+
versionId: info.versionId),
248+
_ => throw UnimplementedError(),
249+
};
250+
}
251+
252+
Future<void> _prefetchPackageInfo() async {
253+
final info = await package_info_plus.PackageInfo.fromPlatform();
254+
_packageInfo = PackageInfo(
255+
version: info.version,
256+
buildNumber: info.buildNumber,
257+
);
258+
}
259+
172260
@override
173261
Future<GlobalStore> loadGlobalStore() {
174262
return LiveGlobalStore.load();
@@ -195,16 +283,6 @@ class LiveZulipBinding extends ZulipBinding {
195283
return url_launcher.closeInAppWebView();
196284
}
197285

198-
@override
199-
Future<BaseDeviceInfo> deviceInfo() async {
200-
final deviceInfo = await device_info_plus.DeviceInfoPlugin().deviceInfo;
201-
return switch (deviceInfo) {
202-
device_info_plus.AndroidDeviceInfo(:var version) => AndroidDeviceInfo(sdkInt: version.sdkInt),
203-
device_info_plus.IosDeviceInfo(:var systemVersion) => IosDeviceInfo(systemVersion: systemVersion),
204-
_ => throw UnimplementedError(),
205-
};
206-
}
207-
208286
@override
209287
Future<void> firebaseInitializeApp({
210288
required firebase_core.FirebaseOptions options}) {

lib/widgets/about_zulip.dart

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
3-
import 'package:package_info_plus/package_info_plus.dart';
43

4+
import '../model/binding.dart';
55
import 'page.dart';
66

77
class AboutZulipPage extends StatefulWidget {
@@ -16,22 +16,11 @@ class AboutZulipPage extends StatefulWidget {
1616
}
1717

1818
class _AboutZulipPageState extends State<AboutZulipPage> {
19-
PackageInfo? _packageInfo;
20-
21-
@override
22-
void initState() {
23-
super.initState();
24-
(() async {
25-
final result = await PackageInfo.fromPlatform();
26-
setState(() {
27-
_packageInfo = result;
28-
});
29-
})();
30-
}
31-
3219
@override
3320
Widget build(BuildContext context) {
3421
final zulipLocalizations = ZulipLocalizations.of(context);
22+
final appVersion = ZulipBinding.instance.packageInfo.version;
23+
3524
return Scaffold(
3625
appBar: AppBar(title: Text(zulipLocalizations.aboutPageTitle)),
3726
body: SingleChildScrollView(
@@ -43,7 +32,7 @@ class _AboutZulipPageState extends State<AboutZulipPage> {
4332
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
4433
ListTile(
4534
title: Text(zulipLocalizations.aboutPageAppVersion),
46-
subtitle: Text(_packageInfo?.version ?? '(…)')),
35+
subtitle: Text(appVersion)),
4736
ListTile(
4837
title: Text(zulipLocalizations.aboutPageOpenSourceLicenses),
4938
subtitle: Text(zulipLocalizations.aboutPageTapToView),

lib/widgets/clipboard.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ void copyWithPopup({
1818
required Widget successContent,
1919
}) async {
2020
await Clipboard.setData(data);
21-
final deviceInfo = await ZulipBinding.instance.deviceInfo();
2221

2322
if (!context.mounted) return;
2423

25-
final shouldShowSnackbar = switch (deviceInfo) {
24+
final shouldShowSnackbar = switch (ZulipBinding.instance.deviceInfo) {
2625
// Android 13+ shows its own popup on copying to the clipboard,
2726
// so we suppress ours, following the advice at:
2827
// https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications

test/api/core_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import 'package:zulip/api/core.dart';
88
import 'package:zulip/api/exception.dart';
99
import 'package:zulip/model/localizations.dart';
1010

11+
import '../model/binding.dart';
1112
import '../stdlib_checks.dart';
1213
import 'exception_checks.dart';
1314
import 'fake_api.dart';
1415
import '../example_data.dart' as eg;
1516

1617
void main() {
18+
TestZulipBinding.ensureInitialized();
19+
1720
test('ApiConnection.get', () async {
1821
Future<void> checkRequest(Map<String, dynamic>? params, String expectedRelativeUrl) {
1922
return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {

test/api/fake_api_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ 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+
912
test('baseline happy case', () async {
1013
final connection = FakeApiConnection();
1114
connection.prepare(json: {'a': 3});

test/api/route/messages_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import 'package:zulip/api/route/messages.dart';
1010
import 'package:zulip/model/narrow.dart';
1111

1212
import '../../example_data.dart' as eg;
13+
import '../../model/binding.dart';
1314
import '../../stdlib_checks.dart';
1415
import '../fake_api.dart';
1516
import 'route_checks.dart';
1617

1718
void main() {
19+
TestZulipBinding.ensureInitialized();
20+
1821
group('getMessageCompat', () {
1922
Future<Message?> checkGetMessageCompat(FakeApiConnection connection, {
2023
required bool expectLegacy,

test/api/route/notifications_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ 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+
1013
group('registerFcmToken', () {
1114
Future<void> checkRegisterFcmToken(FakeApiConnection connection, {
1215
required String token,

test/model/binding.dart

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class TestZulipBinding extends ZulipBinding {
7070
_resetLaunchUrl();
7171
_resetCloseInAppWebView();
7272
_resetDeviceInfo();
73+
_resetPackageInfo();
7374
_resetFirebase();
7475
_resetNotifications();
7576
}
@@ -204,19 +205,25 @@ class TestZulipBinding extends ZulipBinding {
204205
_closeInAppWebViewCallCount++;
205206
}
206207

207-
/// The value that `ZulipBinding.instance.deviceInfo()` should return.
208-
///
209-
/// See also [takeDeviceInfoCalls].
208+
@override
209+
BaseDeviceInfo get deviceInfo => deviceInfoResult;
210+
211+
/// The value that `ZulipBinding.instance.deviceInfo` should return.
210212
BaseDeviceInfo deviceInfoResult = _defaultDeviceInfoResult;
211-
static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33);
213+
static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33, release: '13');
212214

213215
void _resetDeviceInfo() {
214216
deviceInfoResult = _defaultDeviceInfoResult;
215217
}
216218

217219
@override
218-
Future<BaseDeviceInfo> deviceInfo() {
219-
return Future(() => deviceInfoResult);
220+
PackageInfo get packageInfo => packageInfoResult;
221+
222+
PackageInfo packageInfoResult = _defaultPackageInfo;
223+
static final _defaultPackageInfo = PackageInfo(version: '0.0.1', buildNumber: '1');
224+
225+
void _resetPackageInfo() {
226+
packageInfoResult = _defaultPackageInfo;
220227
}
221228

222229
void _resetFirebase() {

0 commit comments

Comments
 (0)