Skip to content

api: Embed platform and app info in user-agent #724

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions lib/api/core.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;

import '../log.dart';
import '../model/binding.dart';
import '../model/localizations.dart';
import 'exception.dart';

Expand Down Expand Up @@ -37,6 +39,7 @@ class ApiConnection {
String? email,
String? apiKey,
required http.Client client,
required this.useBinding,
}) : assert((email != null) == (apiKey != null)),
_authValue = (email != null && apiKey != null)
? _authHeaderValue(email: email, apiKey: apiKey)
Expand All @@ -51,7 +54,7 @@ class ApiConnection {
String? apiKey,
}) : this(client: http.Client(),
realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel,
email: email, apiKey: apiKey);
email: email, apiKey: apiKey, useBinding: true);

final Uri realmUrl;

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

/// Toggles the use of a user-agent generated via [ZulipBinding].
///
/// When set to true, the user-agent will be generated using
/// [ZulipBinding.deviceInfo] and [ZulipBinding.packageInfo].
/// Otherwise, a fallback user-agent [kFallbackUserAgentHeader] will be used.
final bool useBinding;

Map<String, String>? _cachedUserAgentHeader;

void addUserAgent(http.BaseRequest request) {
if (!useBinding) {
request.headers.addAll(kFallbackUserAgentHeader);
return;
}

if (_cachedUserAgentHeader != null) {
request.headers.addAll(_cachedUserAgentHeader!);
return;
}

final deviceInfo = ZulipBinding.instance.syncDeviceInfo;
final packageInfo = ZulipBinding.instance.syncPackageInfo;
if (deviceInfo == null || packageInfo == null) {
request.headers.addAll(kFallbackUserAgentHeader);
return;
}
_cachedUserAgentHeader = _buildUserAgentHeader(deviceInfo, packageInfo);
request.headers.addAll(_cachedUserAgentHeader!);
}

final String? _authValue;

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

addAuth(request);
request.headers.addAll(userAgentHeader());
if (overrideUserAgent != null) {
request.headers['User-Agent'] = overrideUserAgent;
} else {
addUserAgent(request);
}

final http.StreamedResponse response;
Expand Down Expand Up @@ -213,10 +247,47 @@ Map<String, String> authHeader({required String email, required String apiKey})
};
}

/// Fallback user-agent header.
///
/// See documentation on [ApiConnection.useBinding].
@visibleForTesting
const kFallbackUserAgentHeader = {'User-Agent': 'ZulipFlutter'};

Map<String, String> userAgentHeader() {
final deviceInfo = ZulipBinding.instance.syncDeviceInfo;
final packageInfo = ZulipBinding.instance.syncPackageInfo;
if (deviceInfo == null || packageInfo == null) {
return kFallbackUserAgentHeader;
}
return _buildUserAgentHeader(deviceInfo, packageInfo);
}

Map<String, String> _buildUserAgentHeader(BaseDeviceInfo deviceInfo, PackageInfo packageInfo) {
final osInfo = switch (deviceInfo) {
AndroidDeviceInfo(
:var release) => 'Android $release', // "Android 14"
IosDeviceInfo(
:var systemVersion) => 'iOS $systemVersion', // "iOS 17.4"
MacOsDeviceInfo(
:var majorVersion,
:var minorVersion,
:var patchVersion) => 'macOS $majorVersion.$minorVersion.$patchVersion', // "macOS 14.5.0"
WindowsDeviceInfo() => 'Windows', // "Windows"
LinuxDeviceInfo(
:var name,
:var versionId) => 'Linux; $name${versionId != null ? ' $versionId' : ''}', // "Linux; Fedora Linux 40" or "Linux; Fedora Linux"
_ => throw UnimplementedError(),
};
final PackageInfo(:version, :buildNumber) = packageInfo;

// Possible examples:
// 'ZulipFlutter/0.0.15+15 (Android 14)'
// 'ZulipFlutter/0.0.15+15 (iOS 17.4)'
// 'ZulipFlutter/0.0.15+15 (macOS 14.5.0)'
// 'ZulipFlutter/0.0.15+15 (Windows)'
// 'ZulipFlutter/0.0.15+15 (Linux; Fedora Linux 40)'
return {
// TODO(#467) include platform, platform version, and app version
'User-Agent': 'ZulipFlutter',
'User-Agent': 'ZulipFlutter/$version+$buildNumber ($osInfo)',
};
}

Expand Down
2 changes: 1 addition & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ void main() {
return true;
}());
LicenseRegistry.addLicense(additionalLicenses);
LiveZulipBinding.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
LiveZulipBinding.ensureInitialized();
NotificationService.instance.start();
Comment on lines 15 to 18
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these lines need to be reordered?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes they need to be reordered, since deviceInfo is being prefetched in LiveZulipBinding.ensureInitialized() and PlatformChannels require WidgetsFlutterBinding to be initialized first.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the error look like if the order of these gets swapped back?

If it doesn't make clear that the problem is that WidgetsFlutterBinding is needed first, then ideally we can do something to make the error clear.

If that's hard or annoying, then just a comment here explaining this ordering constraint would also be an acceptable solution.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's how the errors look like:

flutter: Failed to prefetch device info: Binding has not yet been initialized.
The "instance" getter on the ServicesBinding binding mixin is only available once that binding has been initialized.
Typically, this is done by calling "WidgetsFlutterBinding.ensureInitialized()" or "runApp()" (the latter calls the former). Typically this call is done in the "void main()" method. The "ensureInitialized" method is idempotent; calling it multiple times is not harmful. After calling that method, the "instance" getter will return the binding.
In a test, one can call "TestWidgetsFlutterBinding.ensureInitialized()" as the first line in the test's "main()" method to initialize the binding.
If ServicesBinding is a custom binding mixin, there must also be a custom binding class, like WidgetsFlutterBinding, but that mixes in the selected binding, and that is the class that must be constructed before using the "instance" getter.
#0      BindingBase.checkInstance.<anonymous closure> (package:flutter/src/foundation/binding.dart:309:9)
#1      BindingBase.checkInstance (package:flutter/src/foundation/binding.dart:390:6)
#2      ServicesBinding.instance (package:flutter/src/services/binding.dart:68:54)
#3      _findBinaryMessenger (package:flutter/src/services/platform_channel.dart:158:25)
#4      MethodChannel.binaryMessenger (package:flutter/src/services/platform_channel.dart:293:56)
#5      MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:327:15)
#6      MethodChannel.invokeMethod (package:flutter/src/services/platform_channel.dart:507:12)
#7      MethodChannelDeviceInfo.deviceInfo (package:device_info_plus_platform_interface/method_channel/method_channel_device_info.dart:19:24)
#8      DeviceInfoPlugin.macOsInfo (package:device_info_plus/device_info_plus.dart:86:48)
#9      DeviceInfoPlugin.deviceInfo (package:device_info_plus/device_info_plus.dart:107:16)
#10     LiveZulipBinding._prefetchDeviceInfo (package:zulip/model/binding.dart:338:62)
#11     new LiveZulipBinding (package:zulip/model/binding.dart:290:19)
#12     LiveZulipBinding.ensureInitialized (package:zulip/model/binding.dart:297:7)
#13     main (package:zulip/main.dart:16:20)
#14     _runMain.<anonymous closure> (dart:ui/hooks.dart:301:23)
#15     _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:297:19)
#16     _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)

flutter: Failed to prefetch package info: Binding has not yet been initialized.
The "instance" getter on the ServicesBinding binding mixin is only available once that binding has been initialized.
Typically, this is done by calling "WidgetsFlutterBinding.ensureInitialized()" or "runApp()" (the latter calls the former). Typically this call is done in the "void main()" method. The "ensureInitialized" method is idempotent; calling it multiple times is not harmful. After calling that method, the "instance" getter will return the binding.
In a test, one can call "TestWidgetsFlutterBinding.ensureInitialized()" as the first line in the test's "main()" method to initialize the binding.
If ServicesBinding is a custom binding mixin, there must also be a custom binding class, like WidgetsFlutterBinding, but that mixes in the selected binding, and that is the class that must be constructed before using the "instance" getter.
#0      BindingBase.checkInstance.<anonymous closure> (package:flutter/src/foundation/binding.dart:309:9)
#1      BindingBase.checkInstance (package:flutter/src/foundation/binding.dart:390:6)
#2      ServicesBinding.instance (package:flutter/src/services/binding.dart:68:54)
#3      _findBinaryMessenger (package:flutter/src/services/platform_channel.dart:158:25)
#4      MethodChannel.binaryMessenger (package:flutter/src/services/platform_channel.dart:293:56)
#5      MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:327:15)
#6      MethodChannel.invokeMethod (package:flutter/src/services/platform_channel.dart:507:12)
#7      MethodChannel.invokeMapMethod (package:flutter/src/services/platform_channel.dart:534:49)
#8      MethodChannelPackageInfo.getAll (package:package_info_plus_platform_interface/method_channel_package_info.dart:13:32)
#9      PackageInfo.fromPlatform (package:package_info_plus/package_info_plus.dart:80:61)
#10     LiveZulipBinding._prefetchPackageInfo (package:zulip/model/binding.dart:367:56)
#11     new LiveZulipBinding (package:zulip/model/binding.dart:291:20)
#12     LiveZulipBinding.ensureInitialized (package:zulip/model/binding.dart:297:7)
#13     main (package:zulip/main.dart:16:20)
#14     _runMain.<anonymous closure> (dart:ui/hooks.dart:301:23)
#15     _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:297:19)
#16     _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)

Basically both of the prefetch fail because of same reason — MethodChannel requiring WidgetsFlutterBinding initialized first.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, that seems clear enough — thanks.

runApp(const ZulipApp());
}
180 changes: 169 additions & 11 deletions lib/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import 'package:firebase_core/firebase_core.dart' as firebase_core;
import 'package:firebase_messaging/firebase_messaging.dart' as firebase_messaging;
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
import 'package:url_launcher/url_launcher.dart' as url_launcher;

import '../host/android_notifications.dart';
import '../log.dart';
import '../widgets/store.dart';
import 'store.dart';

Expand Down Expand Up @@ -101,8 +103,34 @@ abstract class ZulipBinding {
/// Provides device and operating system information,
/// via package:device_info_plus.
///
/// The returned Future resolves to null if an error is
/// encountered while fetching the data.
///
/// This wraps [device_info_plus.DeviceInfoPlugin.deviceInfo].
Future<BaseDeviceInfo> deviceInfo();
Future<BaseDeviceInfo?> get deviceInfo;

/// Provides device and operating system information,
/// via package:device_info_plus.
///
/// This is the value [deviceInfo] resolved to,
/// or null if that hasn't resolved yet.
BaseDeviceInfo? get syncDeviceInfo;

/// Provides application package information,
/// via package:package_info_plus.
///
/// The returned Future resolves to null if an error is
/// encountered while fetching the data.
///
/// This wraps [package_info_plus.PackageInfo.fromPlatform].
Future<PackageInfo?> get packageInfo;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit in commit message:

binding: Add packageInfo getter

This is a preparatory commit for the work of embedding the
device info in user-agent header.

copy-paste error 🙂


/// Provides application package information,
/// via package:package_info_plus.
///
/// This is the value [packageInfo] resolved to,
/// or null if that hasn't resolved yet.
PackageInfo? get syncPackageInfo;

/// Initialize Firebase, to use for notifications.
///
Expand All @@ -128,18 +156,26 @@ abstract class ZulipBinding {

/// Like [device_info_plus.BaseDeviceInfo], but without things we don't use.
abstract class BaseDeviceInfo {
BaseDeviceInfo();
const BaseDeviceInfo();
}

/// Like [device_info_plus.AndroidDeviceInfo], but without things we don't use.
class AndroidDeviceInfo extends BaseDeviceInfo {
/// The Android version string, Build.VERSION.RELEASE, e.g. "14".
///
/// Upstream documents this as an opaque string with no particular structure,
/// but e.g. on stock Android 14 it's "14".
///
/// See: https://developer.android.com/reference/android/os/Build.VERSION#RELEASE
final String release;

/// The Android SDK version.
///
/// Possible values are defined in:
/// https://developer.android.com/reference/android/os/Build.VERSION_CODES.html
final int sdkInt;

AndroidDeviceInfo({required this.sdkInt});
const AndroidDeviceInfo({required this.release, required this.sdkInt});
}

/// Like [device_info_plus.IosDeviceInfo], but without things we don't use.
Expand All @@ -149,7 +185,84 @@ class IosDeviceInfo extends BaseDeviceInfo {
/// See: https://developer.apple.com/documentation/uikit/uidevice/1620043-systemversion
final String systemVersion;

IosDeviceInfo({required this.systemVersion});
const IosDeviceInfo({required this.systemVersion});
}

/// Like [device_info_plus.MacOsDeviceInfo], but without things we don't use.
class MacOsDeviceInfo extends BaseDeviceInfo {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it will also be nice to add the classes for these new platforms and the related changes to LiveZulipBinding._prefetchDeviceInfo in a separate commit too!

/// See: https://developer.apple.com/documentation/foundation/operatingsystemversion/1414662-majorversion
final int majorVersion;

/// See: https://developer.apple.com/documentation/foundation/operatingsystemversion/1413801-minorversion
final int minorVersion;

/// See: https://developer.apple.com/documentation/foundation/operatingsystemversion/1415564-patchversion
final int patchVersion;

const MacOsDeviceInfo({
required this.majorVersion,
required this.minorVersion,
required this.patchVersion,
});
}

/// Like [device_info_plus.WindowsDeviceInfo], currently only used to
/// determine if we're on Windows.
// TODO Determine a method to identify the Windows version.
// Currently, we do not include Windows version information because
// Windows OS does not provide a straightforward way to obtain
// recognizable version information.
// Here's an example of `WindowsDeviceInfo` data[1]. Based on that
// data, there are two possible approaches to identify the Windows
// version:
Comment on lines +211 to +217
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very informative, thanks!

// - One approach is to use a combination of the majorVersion,
// minorVersion, and buildNumber fields. However, this data does
// not directly correspond to recognizable Windows versions
// (for example major=10, minor=0, build=22631 actually represents
// "Windows 11, 23H2"). Refer to the link in this comment[2] for
// Chromium's implementation of parsing Windows version numbers.
// - Another approach is to use the productName field. While this
// field contains the Windows version, it also includes extraneous
// information. For example, some productName strings are:
// "Windows 11 Pro" and "Windows 10 Home Single Language", which
// makes it less ideal.
// [1]: https://gist.github.com/rajveermalviya/58b3add437280cc7f8356f3697099b7c
// [2]: https://github.com/zulip/zulip-flutter/pull/724#discussion_r1628318991
class WindowsDeviceInfo implements BaseDeviceInfo {
const WindowsDeviceInfo();
}

/// Like [device_info_plus.LinuxDeviceInfo], but without things we don't use.
class LinuxDeviceInfo implements BaseDeviceInfo {
/// The operating system name, 'NAME' field in /etc/os-release.
///
/// Examples: 'Fedora', 'Debian GNU/Linux', or just 'Linux'.
///
/// See: https://www.freedesktop.org/software/systemd/man/latest/os-release.html#NAME=
final String name;

/// The operating system version, 'VERSION_ID' field in /etc/os-release.
///
/// This string contains only the version number and excludes the
/// OS name and version codenames.
///
/// Examples: '17', '11.04'.
///
/// See: https://www.freedesktop.org/software/systemd/man/latest/os-release.html#VERSION_ID=
final String? versionId;

const LinuxDeviceInfo({required this.name, required this.versionId});
}

/// Like [package_info_plus.PackageInfo], but without things we don't use.
class PackageInfo {
final String version;
final String buildNumber;

const PackageInfo({
required this.version,
required this.buildNumber,
});
}

/// A concrete binding for use in the live application.
Expand All @@ -161,6 +274,11 @@ class IosDeviceInfo extends BaseDeviceInfo {
/// Methods wrapping a plugin, like [launchUrl], invoke the actual
/// underlying plugin method.
class LiveZulipBinding extends ZulipBinding {
LiveZulipBinding() {
_deviceInfo = _prefetchDeviceInfo();
_packageInfo = _prefetchPackageInfo();
}

/// Initialize the binding if necessary, and ensure it is a [LiveZulipBinding].
static LiveZulipBinding ensureInitialized() {
if (ZulipBinding._instance == null) {
Expand Down Expand Up @@ -196,13 +314,53 @@ class LiveZulipBinding extends ZulipBinding {
}

@override
Future<BaseDeviceInfo> deviceInfo() async {
final deviceInfo = await device_info_plus.DeviceInfoPlugin().deviceInfo;
return switch (deviceInfo) {
device_info_plus.AndroidDeviceInfo(:var version) => AndroidDeviceInfo(sdkInt: version.sdkInt),
device_info_plus.IosDeviceInfo(:var systemVersion) => IosDeviceInfo(systemVersion: systemVersion),
_ => throw UnimplementedError(),
};
Future<BaseDeviceInfo?> get deviceInfo => _deviceInfo;
late Future<BaseDeviceInfo?> _deviceInfo;

@override
BaseDeviceInfo? get syncDeviceInfo => _syncDeviceInfo;
BaseDeviceInfo? _syncDeviceInfo;

Future<BaseDeviceInfo?> _prefetchDeviceInfo() async {
try {
final info = await device_info_plus.DeviceInfoPlugin().deviceInfo;
_syncDeviceInfo = switch (info) {
device_info_plus.AndroidDeviceInfo() => AndroidDeviceInfo(release: info.version.release,
sdkInt: info.version.sdkInt),
device_info_plus.IosDeviceInfo() => IosDeviceInfo(systemVersion: info.systemVersion),
device_info_plus.MacOsDeviceInfo() => MacOsDeviceInfo(majorVersion: info.majorVersion,
minorVersion: info.minorVersion,
patchVersion: info.patchVersion),
device_info_plus.WindowsDeviceInfo() => const WindowsDeviceInfo(),
device_info_plus.LinuxDeviceInfo() => LinuxDeviceInfo(name: info.name,
versionId: info.versionId),
_ => throw UnimplementedError(),
};
} catch (e, st) {
assert(debugLog('Failed to prefetch device info: $e\n$st')); // TODO(log)
}
return _syncDeviceInfo;
}

@override
Future<PackageInfo?> get packageInfo => _packageInfo;
late Future<PackageInfo?> _packageInfo;

@override
PackageInfo? get syncPackageInfo => _syncPackageInfo;
PackageInfo? _syncPackageInfo;

Future<PackageInfo?> _prefetchPackageInfo() async {
try {
final info = await package_info_plus.PackageInfo.fromPlatform();
_syncPackageInfo = PackageInfo(
version: info.version,
buildNumber: info.buildNumber,
);
} catch (e, st) {
assert(debugLog('Failed to prefetch package info: $e\n$st')); // TODO(log)
}
return _syncPackageInfo;
}

@override
Expand Down
4 changes: 2 additions & 2 deletions lib/widgets/about_zulip.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
import 'package:package_info_plus/package_info_plus.dart';

import '../model/binding.dart';
import 'page.dart';

class AboutZulipPage extends StatefulWidget {
Expand All @@ -22,7 +22,7 @@ class _AboutZulipPageState extends State<AboutZulipPage> {
void initState() {
super.initState();
(() async {
final result = await PackageInfo.fromPlatform();
final result = await ZulipBinding.instance.packageInfo;
setState(() {
_packageInfo = result;
});
Expand Down
Loading