Skip to content
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
1 change: 1 addition & 0 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
- `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user
- `TokenManager.getToken` fails when `reset` runs while the token is loading, and rejects a token whose `user_id` is not the user it was loading for; a `setTokenProvider` during a load still serves the caller that started it
- `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token
- `SystemEnvironmentManager.updateEnvironment` now sanitizes the passed `SystemEnvironment`, so an integrator can enrich the Stream client header without changing the SDK identity it reports

## 0.4.0

Expand Down
111 changes: 106 additions & 5 deletions packages/stream_core/lib/src/api/system_environment_manager.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
// ignore_for_file: use_setters_to_change_properties

import 'system_environment.dart';

/// {@template systemEnvironmentManager}
/// A manager class to handle the current [SystemEnvironment].
///
/// The [SystemEnvironment] passed to the constructor is the SDK-owned
/// baseline, and is trusted as-is: only the SDK that builds the manager is
/// meant to provide it. Every environment passed to [updateEnvironment] is
/// sanitized against that baseline, so an integrator can enrich the Stream
/// client header without changing the SDK identity it reports.
/// {@endtemplate}
class SystemEnvironmentManager {
/// {@macro systemEnvironmentManager}
SystemEnvironmentManager({
required this._environment,
});
required SystemEnvironment environment,
}) : _sdkName = environment.sdkName,
_sdkVersion = environment.sdkVersion,
_osName = environment.osName,
_environment = environment;

// The SDK-owned values every update is sanitized against.
//
// Snapshotted rather than kept as a SystemEnvironment reference: the type is
// not final, so a subtype could return something different on every getter
// read and drift the values an update is supposed to be locked to.
final String _sdkName;
final String _sdkVersion;
final String? _osName;

/// Returns the Stream client user agent string based on the current
/// [environment] value.
Expand All @@ -20,8 +36,72 @@ class SystemEnvironmentManager {
SystemEnvironment _environment;

/// Updates the current [SystemEnvironment].
///
/// The passed [environment] is sanitized against the environment this manager
/// was constructed with, so the SDK identity reported in the Stream client
/// header cannot be changed after construction.
///
/// The following fields are applied as passed.
///
/// - [SystemEnvironment.appName]
/// - [SystemEnvironment.appVersion]
/// - [SystemEnvironment.osVersion]
/// - [SystemEnvironment.deviceModel]
///
/// The following fields keep the value given to the constructor, and custom
/// values for them are ignored.
///
/// - [SystemEnvironment.sdkName]
/// - [SystemEnvironment.sdkVersion]
/// - [SystemEnvironment.osName]
///
/// [SystemEnvironment.sdkIdentifier] is a partial exception: only the `dart`
/// to `flutter` promotion is accepted. Any other value, including a `flutter`
/// to `dart` demotion or an unrecognized identifier, is ignored.
///
/// Fields are replaced rather than merged, so a field left out of
/// [environment] is cleared rather than carried over from a previous update.
///
/// For a manager constructed with `sdkName` `stream-feeds`, `sdkIdentifier`
/// `dart` and `sdkVersion` `1.0.0`:
///
/// ```dart
/// manager.updateEnvironment(
/// const SystemEnvironment(
/// sdkName: 'spoofed', // Ignored.
/// sdkIdentifier: 'dart',
/// sdkVersion: '9.9.9', // Ignored.
/// appName: 'MyApp', // Applied.
/// ),
/// );
///
/// print(manager.userAgent); // stream-feeds-dart-v1.0.0|app=MyApp
/// ```
void updateEnvironment(SystemEnvironment environment) {
_environment = environment;
_environment = _sanitize(environment);
}

// Rebuilds the passed environment with the SDK-owned fields restored from
// the baseline.
//
// Always returns a fresh instance. SystemEnvironment is not final, so the
// passed value may be a subtype whose getters return something different on
// every read; its fields are copied out once here and it is never handed on
// directly. Do not add a fast path that returns the passed instance.
SystemEnvironment _sanitize(SystemEnvironment environment) {
final current = _SdkIdentifier(_environment.sdkIdentifier);
final proposed = _SdkIdentifier(environment.sdkIdentifier);

return SystemEnvironment(
sdkName: _sdkName,
sdkIdentifier: current.resolveUpdate(proposed),
sdkVersion: _sdkVersion,
appName: environment.appName,
appVersion: environment.appVersion,
osName: _osName,
osVersion: environment.osVersion,
deviceModel: environment.deviceModel,
);
}
}

Expand Down Expand Up @@ -53,3 +133,24 @@ extension XStreamClientHeaderExtension on SystemEnvironment {
].nonNulls.join('|');
}
}

// Known SDK identifiers, ranked so that the dart to flutter transition is
// one-way and unrecognized identifiers are never accepted.
extension type const _SdkIdentifier(String value) implements String {
static const dart = _SdkIdentifier('dart');
static const flutter = _SdkIdentifier('flutter');

int get _precedence => switch (this) {
dart => 0,
flutter => 1,
_ => -1,
};

// Returns the identifier to keep when [proposed] is offered as a
// replacement for this one.
_SdkIdentifier resolveUpdate(_SdkIdentifier proposed) {
if (proposed._precedence < 0) return this;
if (proposed._precedence < _precedence) return this;
return proposed;
}
}
176 changes: 176 additions & 0 deletions packages/stream_core/test/api/system_environment_manager_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import 'package:stream_core/stream_core.dart';
import 'package:test/test.dart';

void main() {
test('keeps the environment passed to the constructor', () {
final manager = SystemEnvironmentManager(environment: _baseline);

expect(manager.environment.sdkName, 'stream-sdk');
expect(manager.environment.sdkIdentifier, 'dart');
expect(manager.environment.sdkVersion, '1.2.3');
expect(manager.environment.osName, 'ios');
});

test('builds the user agent from the current environment', () {
final manager = SystemEnvironmentManager(environment: _baseline);

expect(manager.userAgent, 'stream-sdk-dart-v1.2.3|os=ios');
});

test('ignores sdkName, sdkVersion and osName passed to an update', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager.updateEnvironment(
const SystemEnvironment(
sdkName: 'stream-chat',
sdkIdentifier: 'dart',
sdkVersion: '99.0.0',
osName: 'spoofed-os',
),
);

expect(manager.environment.sdkName, 'stream-sdk');
expect(manager.environment.sdkVersion, '1.2.3');
expect(manager.environment.osName, 'ios');
});

test('passes app, os version and device fields through on an update', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager.updateEnvironment(
const SystemEnvironment(
sdkName: 'stream-sdk',
sdkIdentifier: 'dart',
sdkVersion: '1.2.3',
appName: 'test-app',
appVersion: '2.0.0',
osVersion: '14',
deviceModel: 'Pixel 7',
),
);

expect(manager.environment.appName, 'test-app');
expect(manager.environment.appVersion, '2.0.0');
expect(manager.environment.osVersion, '14');
expect(manager.environment.deviceModel, 'Pixel 7');
});

test('promotes sdkIdentifier from dart to flutter', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager.updateEnvironment(_identifiedAs('flutter'));

expect(manager.environment.sdkIdentifier, 'flutter');
});

test('ignores a demotion of sdkIdentifier from flutter to dart', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager
..updateEnvironment(_identifiedAs('flutter'))
..updateEnvironment(_identifiedAs('dart'));

expect(manager.environment.sdkIdentifier, 'flutter');
});

test('keeps a promoted sdkIdentifier across later updates', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager
..updateEnvironment(_identifiedAs('flutter'))
..updateEnvironment(_identifiedAs('dart', appName: 'test-app'));

expect(manager.environment.sdkIdentifier, 'flutter');
expect(manager.environment.appName, 'test-app');
});

test('ignores an unrecognized sdkIdentifier', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager.updateEnvironment(_identifiedAs('android'));

expect(manager.environment.sdkIdentifier, 'dart');
});

test('ignores an unrecognized sdkIdentifier when the baseline is unrecognized too', () {
final manager = SystemEnvironmentManager(environment: _identifiedAs('dart-io'));

manager.updateEnvironment(_identifiedAs('android'));

expect(manager.environment.sdkIdentifier, 'dart-io');
});

test('accepts a known sdkIdentifier when the baseline is unrecognized', () {
final manager = SystemEnvironmentManager(environment: _identifiedAs('dart-io'));

manager.updateEnvironment(_identifiedAs('flutter'));

expect(manager.environment.sdkIdentifier, 'flutter');
});

test('keeps the locked fields at the baseline across repeated updates', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager
..updateEnvironment(_identifiedAs('dart', appName: 'first-app'))
..updateEnvironment(_identifiedAs('dart', appName: 'second-app'));

expect(manager.environment.appName, 'second-app');
expect(manager.environment.sdkName, 'stream-sdk');
expect(manager.environment.sdkVersion, '1.2.3');
expect(manager.environment.osName, 'ios');
});

test('keeps the locked fields fixed when the baseline object mutates', () {
final manager = SystemEnvironmentManager(environment: _MutatingBaseline());

final observed = <String>[];
for (var i = 0; i < 3; i++) {
manager.updateEnvironment(_identifiedAs('dart'));
observed.add(manager.environment.sdkName);
}

expect(observed, ['v0', 'v0', 'v0']);
});

test('clears a pass-through field left out of a later update', () {
final manager = SystemEnvironmentManager(environment: _baseline);

manager
..updateEnvironment(_identifiedAs('dart', appName: 'test-app'))
..updateEnvironment(_identifiedAs('dart'));

expect(manager.environment.appName, isNull);
});
}

// A baseline whose sdkName changes on every getter read. SystemEnvironment is
// not final, so the manager must snapshot the SDK-owned values at construction
// rather than re-read them from the instance it was given.
class _MutatingBaseline extends SystemEnvironment {
_MutatingBaseline() : super(sdkName: 'v0', sdkIdentifier: 'dart', sdkVersion: '1.0.0');

var _reads = 0;

@override
String get sdkName => 'v${_reads++}';
}

// The baseline a product SDK owns and passes to the constructor.
const _baseline = SystemEnvironment(
sdkName: 'stream-sdk',
sdkIdentifier: 'dart',
sdkVersion: '1.2.3',
osName: 'ios',
);

// An update as an integrator would send it: only the identifier and the
// overridable fields differ from the baseline.
SystemEnvironment _identifiedAs(String sdkIdentifier, {String? appName}) {
return SystemEnvironment(
sdkName: _baseline.sdkName,
sdkIdentifier: sdkIdentifier,
sdkVersion: _baseline.sdkVersion,
appName: appName,
);
}
Loading
Loading