From d7de531dd61dac1e2c476325903eaf657e6f080b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 17 Jun 2026 15:03:15 +0200 Subject: [PATCH 01/31] feat(llc): implement real guest token flow via createGuest Guest users (User.guest) now call POST /api/v2/guest to mint a temporary JWT on connect() instead of reusing the anonymous token. This gives guest users a full authenticated WebSocket session. Anonymous users are unchanged: they continue to use the static anonymous token with no WS connection. Closes FLU-373 Co-Authored-By: Claude Opus 4.8 --- docs/code_snippets/02_02_authentication.dart | 58 +++++++++++++++++++ packages/stream_feeds/CHANGELOG.md | 3 + .../lib/src/client/feeds_client_impl.dart | 34 ++++++++++- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 docs/code_snippets/02_02_authentication.dart diff --git a/docs/code_snippets/02_02_authentication.dart b/docs/code_snippets/02_02_authentication.dart new file mode 100644 index 00000000..c77568c3 --- /dev/null +++ b/docs/code_snippets/02_02_authentication.dart @@ -0,0 +1,58 @@ +import 'package:stream_feeds/stream_feeds.dart'; + +Future regularUserLogin() async { + // Regular user: provide a JWT token (from your server). + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + ); + await client.connect(); +} + +Future dynamicTokenProvider() async { + // Dynamic token provider: fetches a new token from your server + // when the current one expires. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.dynamic((userId) async { + // Fetch a fresh JWT for `userId` from your backend. + final token = await fetchTokenFromYourServer(userId); + return UserToken(token); + }), + ); + await client.connect(); +} + +// Placeholder for your server token fetch +Future fetchTokenFromYourServer(String userId) async => ''; + +Future guestUserLogin() async { + // Guest user: the SDK automatically calls POST /api/v2/guest to obtain + // a temporary JWT — no tokenProvider is needed. + // Guest users have full read/write access and a real WebSocket connection, + // but their session is temporary and not tied to a persistent account. + final client = StreamFeedsClient( + apiKey: '', + user: User.guest('guest-${DateTime.now().millisecondsSinceEpoch}'), + ); + await client.connect(); // Guest JWT is fetched automatically on connect. + + final feed = client.feed(group: 'user', id: client.user.id); + await feed.getOrCreate(); +} + +Future anonymousUserLogin() async { + // Anonymous user: read-only access with no JWT or WebSocket connection. + // Use this for public feeds that don't require authentication. + // Note: calling connect() throws for anonymous users. + final client = StreamFeedsClient( + apiKey: '', + user: const User.anonymous(), + ); + + // Read public feed data without connecting. + final feed = client.feed(group: 'user', id: 'alice'); + await feed.getOrCreate(); +} diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 1b00fdc3..2df5a326 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,5 +1,8 @@ ## Upcoming +### Improvements +- Guest users (`User.guest(id)`) now obtain a real JWT by calling `POST /api/v2/guest` during `connect()`, giving them a full authenticated session with WebSocket support. Previously guest users fell back to the anonymous token which prevented WS connectivity. + ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. - Added `friendReactionCount` and `friendReactions` fields to `ActivityData` to expose reactions from friends. diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 34c8c176..9cec7776 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -84,9 +84,41 @@ class StreamFeedsClientImpl implements StreamFeedsClient { (UserType.regular, null) => throw ArgumentError( 'TokenProvider must be provided for regular users.', ), - (UserType.anonymous || UserType.guest, _) => TokenProvider.static( + (UserType.anonymous, _) => TokenProvider.static( UserToken.anonymous(userId: user.id), ), + (UserType.guest, _) => TokenProvider.dynamic((_) async { + // Create a minimal unauthenticated HTTP client for the guest endpoint. + // This client only carries the API key header — no auth token needed. + final guestHttpClient = + StreamCoreHttpClient( + options: BaseOptions( + baseUrl: endpointConfig.baseFeedsUrl, + connectTimeout: const Duration(seconds: 6), + receiveTimeout: const Duration(seconds: 6), + ), + ).apply( + (client) => client.interceptors.addAll([ + ApiKeyInterceptor(apiKey), + HeadersInterceptor(_systemEnvironmentManager), + const ApiErrorInterceptor(), + ]), + ); + + final guestApi = api.DefaultApi(guestHttpClient); + final result = await guestApi.createGuest( + createGuestRequest: api.CreateGuestRequest( + user: api.UserRequest( + id: user.id, + name: user.originalName, + image: user.image, + custom: user.custom.isEmpty ? null : user.custom, + ), + ), + ); + + return result.map((r) => UserToken(r.accessToken)).getOrThrow(); + }), }; _tokenManager = TokenManager( From d5df62f6121161f80116565add5a7a83fe189d32 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 18 Jun 2026 10:06:08 +0200 Subject: [PATCH 02/31] chore: trigger CI re-run Co-Authored-By: Claude Sonnet 4.6 From d3e686a4fce19606cce7096cc9c08ce681f67292 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 18 Jun 2026 10:26:10 +0200 Subject: [PATCH 03/31] test(llc): add coverage for guest user token flow Inject guestRestApi into StreamFeedsClientImpl to make the guest createGuest() call testable. Add unit test for the guest user token flow via createGuest. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/src/client/feeds_client_impl.dart | 3 +- .../stream_feeds/lib/src/feeds_client.dart | 1 + .../test/client/feeds_client_test.dart | 37 +++++++++++++++++++ .../lib/src/testers/base_tester.dart | 1 + 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 9cec7776..8c7c7bab 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -73,6 +73,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { List? reconnectionPolicies, WebSocketProvider? wsProvider, api.DefaultApi? feedsRestApi, + api.DefaultApi? guestRestApi, }) { // TODO: Make this configurable const endpointConfig = EndpointConfig.production; @@ -105,7 +106,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { ]), ); - final guestApi = api.DefaultApi(guestHttpClient); + final guestApi = guestRestApi ?? api.DefaultApi(guestHttpClient); final result = await guestApi.createGuest( createGuestRequest: api.CreateGuestRequest( user: api.UserRequest( diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index b2a73054..e169b664 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -169,6 +169,7 @@ abstract interface class StreamFeedsClient { List? reconnectionPolicies, @visibleForTesting WebSocketProvider? wsProvider, @visibleForTesting api.DefaultApi? feedsRestApi, + @visibleForTesting api.DefaultApi? guestRestApi, }) = StreamFeedsClientImpl; User get user; diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index f1c877e4..d7a90081 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -935,4 +935,41 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Guest User Authentication + // ============================================================ + + group('connect as guest user', () { + feedsClientTest( + 'should connect a guest user using the createGuest token flow', + user: User.guest('guest-123'), + connect: (tester) async { + tester.mockApi( + (api) => api.createGuest( + createGuestRequest: CreateGuestRequest( + user: UserRequest( + id: 'guest-123', + name: 'guest-123', + ), + ), + ), + result: CreateGuestResponse( + accessToken: generateTestUserToken('guest-123').rawValue, + duration: '10ms', + user: createDefaultUserResponse(id: 'guest-123'), + ), + ); + tester.mockSuccessfulAuth('guest-123'); + await tester.client.connect(); + addTearDown(tester.client.disconnect); + }, + body: (tester) async { + expect( + tester.client.connectionState.value, + isA(), + ); + }, + ); + }); } diff --git a/packages/stream_feeds_test/lib/src/testers/base_tester.dart b/packages/stream_feeds_test/lib/src/testers/base_tester.dart index 7a3a98d5..9bc73d10 100644 --- a/packages/stream_feeds_test/lib/src/testers/base_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/base_tester.dart @@ -270,6 +270,7 @@ void testWithTester>( generateTestUserToken(user.id), ), feedsRestApi: feedsApi, + guestRestApi: feedsApi, wsProvider: (options) => webSocketChannel, config: FeedsConfig( cdnClient: FeedsCdnClient(cdnApi), From 477415c44a69c0031409e4c5ec76f9b86eacb3e9 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 18 Jun 2026 10:27:31 +0200 Subject: [PATCH 04/31] test(llc): fix lint warnings in guest user test Co-Authored-By: Claude Sonnet 4.6 --- packages/stream_feeds/test/client/feeds_client_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index d7a90081..22b7ebae 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -943,11 +943,11 @@ void main() { group('connect as guest user', () { feedsClientTest( 'should connect a guest user using the createGuest token flow', - user: User.guest('guest-123'), + user: const User.guest('guest-123'), connect: (tester) async { tester.mockApi( (api) => api.createGuest( - createGuestRequest: CreateGuestRequest( + createGuestRequest: const CreateGuestRequest( user: UserRequest( id: 'guest-123', name: 'guest-123', @@ -964,7 +964,7 @@ void main() { await tester.client.connect(); addTearDown(tester.client.disconnect); }, - body: (tester) async { + body: (tester) { expect( tester.client.connectionState.value, isA(), From c1992649ae79cbdc5936039e72655271beabbd12 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 10 Jul 2026 12:21:27 +0200 Subject: [PATCH 05/31] Fix guest token userId --- melos.yaml | 3 +- packages/stream_feeds/CHANGELOG.md | 2 +- .../lib/src/client/feeds_client_impl.dart | 160 ++++++++++++------ packages/stream_feeds/pubspec.yaml | 3 +- .../test/client/feeds_client_test.dart | 60 ++++++- 5 files changed, 165 insertions(+), 63 deletions(-) diff --git a/melos.yaml b/melos.yaml index 042faa0d..c721d276 100644 --- a/melos.yaml +++ b/melos.yaml @@ -47,7 +47,8 @@ command: shared_preferences: ^2.5.3 state_notifier: ^1.0.0 stream_feeds: ^0.5.1 - stream_core: ^0.4.0 + stream_core: + path: /Users/renefloor/Documents/github/stream-core-flutter/packages/stream_core video_player: ^2.10.0 uuid: ^4.5.1 web_socket_channel: ^3.0.0 diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 2df5a326..07e53263 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,7 +1,7 @@ ## Upcoming ### Improvements -- Guest users (`User.guest(id)`) now obtain a real JWT by calling `POST /api/v2/guest` during `connect()`, giving them a full authenticated session with WebSocket support. Previously guest users fell back to the anonymous token which prevented WS connectivity. +- Guest users (`User.guest(id)`) now obtain a real JWT by calling `POST /api/v2/guest` during `connect()`, giving them a full authenticated session with WebSocket support. Previously guest users fell back to the anonymous token which prevented WS connectivity. If the backend assigns a different id to the guest user, `client.user` is updated to match it. ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 8c7c7bab..8f6aa368 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -61,10 +61,17 @@ import '../state/query/polls_query.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; +// Shared REST client options for both the main and guest-token HTTP clients. +BaseOptions _restApiOptions(EndpointConfig endpointConfig) => BaseOptions( + baseUrl: endpointConfig.baseFeedsUrl, + connectTimeout: const Duration(seconds: 6), + receiveTimeout: const Duration(seconds: 6), +); + class StreamFeedsClientImpl implements StreamFeedsClient { StreamFeedsClientImpl({ required this.apiKey, - required this.user, + required User user, this.config = const FeedsConfig(), TokenProvider? tokenProvider, RetryStrategy? retryStrategy, @@ -74,7 +81,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { WebSocketProvider? wsProvider, api.DefaultApi? feedsRestApi, api.DefaultApi? guestRestApi, - }) { + }) : _user = user { // TODO: Make this configurable const endpointConfig = EndpointConfig.production; @@ -88,40 +95,20 @@ class StreamFeedsClientImpl implements StreamFeedsClient { (UserType.anonymous, _) => TokenProvider.static( UserToken.anonymous(userId: user.id), ), - (UserType.guest, _) => TokenProvider.dynamic((_) async { - // Create a minimal unauthenticated HTTP client for the guest endpoint. - // This client only carries the API key header — no auth token needed. - final guestHttpClient = - StreamCoreHttpClient( - options: BaseOptions( - baseUrl: endpointConfig.baseFeedsUrl, - connectTimeout: const Duration(seconds: 6), - receiveTimeout: const Duration(seconds: 6), - ), - ).apply( - (client) => client.interceptors.addAll([ - ApiKeyInterceptor(apiKey), - HeadersInterceptor(_systemEnvironmentManager), - const ApiErrorInterceptor(), - ]), - ); - - final guestApi = guestRestApi ?? api.DefaultApi(guestHttpClient); - final result = await guestApi.createGuest( - createGuestRequest: api.CreateGuestRequest( - user: api.UserRequest( - id: user.id, - name: user.originalName, - image: user.image, - custom: user.custom.isEmpty ? null : user.custom, - ), - ), - ); - - return result.map((r) => UserToken(r.accessToken)).getOrThrow(); - }), + (UserType.guest, _) => _guestTokenProvider( + user: user, + apiKey: apiKey, + endpointConfig: endpointConfig, + guestRestApi: guestRestApi, + ), }; + // Note: `_tokenManager.userId` stays fixed to the originally-requested + // id for the lifetime of this client, even though a guest user's + // `_user.id` may later be reassigned by the server (see + // `_guestTokenProvider`). REST calls stay consistent regardless, since + // `AuthInterceptor` derives the `user_id` query parameter from the + // resolved token itself rather than from `_tokenManager.userId`. _tokenManager = TokenManager( userId: user.id, tokenProvider: userTokenProvider, @@ -168,11 +155,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final httpClient = StreamCoreHttpClient( - options: BaseOptions( - baseUrl: endpointConfig.baseFeedsUrl, - connectTimeout: const Duration(seconds: 6), - receiveTimeout: const Duration(seconds: 6), - ), + options: _restApiOptions(endpointConfig), ).apply( (client) => client.interceptors.addAll([ ApiKeyInterceptor(apiKey), @@ -217,8 +200,12 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final String apiKey; + // The current user identity. Mutable because a guest user's id/profile may + // be reassigned by the server once the guest token exchange completes; see + // [_guestTokenProvider]. @override - final User user; + User get user => _user; + User _user; final FeedsConfig config; @@ -259,21 +246,88 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override late final ModerationClient moderation; + /// Builds the [TokenProvider] used to obtain a guest JWT. + /// + /// Guest users have no pre-issued token, so one is minted lazily via + /// `POST /api/v2/guest`, called through a dedicated, unauthenticated HTTP + /// client (built once and reused across token refreshes). The backend may + /// return a different id than the one requested, to avoid colliding with + /// an existing user, so [_user] is updated to the server's response to + /// keep the WS handshake and any `client.user` reads in sync with the + /// identity the token actually authenticates as. + TokenProvider _guestTokenProvider({ + required User user, + required String apiKey, + required EndpointConfig endpointConfig, + api.DefaultApi? guestRestApi, + }) { + var guestApi = guestRestApi; + + return TokenProvider.dynamic((_) async { + final guestApiClient = guestApi ??= api.DefaultApi( + StreamCoreHttpClient(options: _restApiOptions(endpointConfig)).apply( + (client) => client.interceptors.addAll([ + ApiKeyInterceptor(apiKey), + HeadersInterceptor(_systemEnvironmentManager), + const ApiErrorInterceptor(), + ]), + ), + ); + + final result = await guestApiClient.createGuest( + createGuestRequest: api.CreateGuestRequest( + user: api.UserRequest( + id: user.id, + name: user.originalName, + image: user.image, + custom: user.custom.isEmpty ? null : user.custom, + ), + ), + ); + final response = result.getOrThrow(); + + _user = User( + id: response.user.id, + name: response.user.name, + image: response.user.image, + role: response.user.role, + type: UserType.guest, + custom: response.user.custom, + ); + + return UserToken(response.accessToken); + }); + } + Future _authenticateUser() async { - final userToken = await _tokenManager.getToken(); - - final connectUserRequest = WsAuthMessageRequest( - products: const ['feeds'], - token: userToken.rawValue, - userDetails: ConnectUserDetailsRequest( - id: user.id, - name: user.originalName, - image: user.image, - custom: user.custom, - ), - ); + try { + final userToken = await _tokenManager.getToken(); + + final connectUserRequest = WsAuthMessageRequest( + products: const ['feeds'], + token: userToken.rawValue, + userDetails: ConnectUserDetailsRequest( + id: user.id, + name: user.originalName, + image: user.image, + custom: user.custom, + ), + ); - _ws.send(connectUserRequest); + _ws.send(connectUserRequest); + } catch (error) { + // Without this, a token-loading failure (e.g. the guest exchange + // failing) would leave the connection stuck in `Authenticating` + // forever, since nothing else observes this callback's Future and + // `connect()` only resolves on a `Connected`/`Disconnected` state. + // + // The default `userInitiated` source (rather than `serverInitiated`) is + // used deliberately: this is a client-side failure to obtain a token + // at all, not a retryable server condition, and `serverInitiated` is + // eligible for automatic reconnection, which would otherwise retry + // the failing token load indefinitely. + await _ws.disconnect(); + } } @override diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 126265b0..e4db9ec7 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -30,7 +30,8 @@ dependencies: retrofit: ^4.9.2 rxdart: ^0.28.0 state_notifier: ^1.0.0 - stream_core: ^0.4.0 + stream_core: + path: /Users/renefloor/Documents/github/stream-core-flutter/packages/stream_core uuid: ^4.5.1 dev_dependencies: diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index 22b7ebae..beb723ce 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -948,19 +948,22 @@ void main() { tester.mockApi( (api) => api.createGuest( createGuestRequest: const CreateGuestRequest( - user: UserRequest( - id: 'guest-123', - name: 'guest-123', - ), + user: UserRequest(id: 'guest-123'), ), ), + // The backend may reassign the id to avoid colliding with an + // existing user, so the mocked response intentionally differs + // from the requested id. result: CreateGuestResponse( - accessToken: generateTestUserToken('guest-123').rawValue, + accessToken: generateTestUserToken('guest-123-xyz').rawValue, duration: '10ms', - user: createDefaultUserResponse(id: 'guest-123'), + user: createDefaultUserResponse( + id: 'guest-123-xyz', + role: 'guest', + ), ), ); - tester.mockSuccessfulAuth('guest-123'); + tester.mockSuccessfulAuth('guest-123-xyz'); await tester.client.connect(); addTearDown(tester.client.disconnect); }, @@ -969,6 +972,49 @@ void main() { tester.client.connectionState.value, isA(), ); + + // The client's exposed identity should be reconciled with the + // server-assigned guest user, not the originally-requested id. + expect(tester.client.user.id, 'guest-123-xyz'); + expect(tester.client.user.type, UserType.guest); + }, + ); + + feedsClientTest( + 'should fail to connect a guest user when the createGuest call fails', + user: const User.guest('guest-123'), + connect: (tester) { + // Wires up the WebSocket mock so the connection can open; the auth + // handshake it configures is never reached since createGuest fails + // before a WsAuthMessageRequest is ever sent. + tester.mockSuccessfulAuth('guest-123'); + tester.mockApiFailure( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest( + user: UserRequest(id: 'guest-123'), + ), + ), + error: Exception('Failed to create guest'), + ); + }, + body: (tester) async { + final connectionStateExpectation = expectLater( + tester.client.connectionState, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + isA(), + ]), + ); + + await expectLater( + tester.client.connect(), + throwsA(isA()), + ); + + await connectionStateExpectation; }, ); }); From a84144d87b5d7895dc7663ee77fb77b9dce40230 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 11 Aug 2026 11:53:38 +0200 Subject: [PATCH 06/31] Make tokenManager mutable --- .../lib/src/client/feeds_client_impl.dart | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 8f6aa368..c7bb161c 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -103,12 +103,10 @@ class StreamFeedsClientImpl implements StreamFeedsClient { ), }; - // Note: `_tokenManager.userId` stays fixed to the originally-requested - // id for the lifetime of this client, even though a guest user's - // `_user.id` may later be reassigned by the server (see - // `_guestTokenProvider`). REST calls stay consistent regardless, since - // `AuthInterceptor` derives the `user_id` query parameter from the - // resolved token itself rather than from `_tokenManager.userId`. + // For guest users this starts with the originally-requested id and is + // swapped for a manager carrying the server-resolved id once the token + // exchange completes (see `_guestTokenProvider`). `AuthInterceptor` reads + // the manager through a getter, so it always sees the current instance. _tokenManager = TokenManager( userId: user.id, tokenProvider: userTokenProvider, @@ -161,7 +159,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { ApiKeyInterceptor(apiKey), HeadersInterceptor(_systemEnvironmentManager), if (user.type != UserType.anonymous) connectionIdInterceptor, - AuthInterceptor(client, _tokenManager), + AuthInterceptor(client, () => _tokenManager), const ApiErrorInterceptor(), LoggingInterceptor(requestHeader: true), ]), @@ -209,7 +207,10 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final FeedsConfig config; - late final TokenManager _tokenManager; + // Not `final`: for guest users this is swapped for a manager carrying the + // server-resolved user id once the token exchange completes (see + // `_guestTokenProvider`). + late TokenManager _tokenManager; late final StreamWebSocketClient _ws; late final ConnectionRecoveryHandler _connectionRecoveryHandler; @@ -250,11 +251,16 @@ class StreamFeedsClientImpl implements StreamFeedsClient { /// /// Guest users have no pre-issued token, so one is minted lazily via /// `POST /api/v2/guest`, called through a dedicated, unauthenticated HTTP - /// client (built once and reused across token refreshes). The backend may - /// return a different id than the one requested, to avoid colliding with - /// an existing user, so [_user] is updated to the server's response to - /// keep the WS handshake and any `client.user` reads in sync with the - /// identity the token actually authenticates as. + /// client. The backend may return a different id than the one requested, to + /// avoid colliding with an existing user, so [_user] is updated to the + /// server's response to keep the WS handshake and any `client.user` reads in + /// sync with the identity the token actually authenticates as. + /// + /// Once the id is known, [_tokenManager] is swapped for one pinned to that id + /// with a static provider: the guest identity is established once (like an + /// anonymous user) rather than re-minted on every token load, and + /// `AuthInterceptor` — which reads the manager through a getter — picks up + /// the resolved id for the `user_id` query parameter. TokenProvider _guestTokenProvider({ required User user, required String apiKey, @@ -295,7 +301,16 @@ class StreamFeedsClientImpl implements StreamFeedsClient { custom: response.user.custom, ); - return UserToken(response.accessToken); + final token = UserToken(response.accessToken); + + // Pin the manager to the resolved id so subsequent REST/WS calls use it + // without re-running the guest exchange. + _tokenManager = TokenManager( + userId: response.user.id, + tokenProvider: TokenProvider.static(token), + ); + + return token; }); } From 6d2b98ce2c9cb3549c6599045dc8b0059e5a27db Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 14 Aug 2026 12:22:24 +0200 Subject: [PATCH 07/31] update core dependency --- melos.yaml | 5 ++++- packages/stream_feeds/lib/src/client/feeds_client_impl.dart | 5 ++++- packages/stream_feeds/pubspec.yaml | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/melos.yaml b/melos.yaml index c721d276..68d25152 100644 --- a/melos.yaml +++ b/melos.yaml @@ -48,7 +48,10 @@ command: state_notifier: ^1.0.0 stream_feeds: ^0.5.1 stream_core: - path: /Users/renefloor/Documents/github/stream-core-flutter/packages/stream_core + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 718e2730d2380db9a61575e006fe1a89dc3e5e2a + path: packages/stream_core video_player: ^2.10.0 uuid: ^4.5.1 web_socket_channel: ^3.0.0 diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index c7bb161c..4e472e37 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -159,7 +159,10 @@ class StreamFeedsClientImpl implements StreamFeedsClient { ApiKeyInterceptor(apiKey), HeadersInterceptor(_systemEnvironmentManager), if (user.type != UserType.anonymous) connectionIdInterceptor, - AuthInterceptor(client, () => _tokenManager), + AuthInterceptor.withProvider( + client, + tokenManagerProvider: () => _tokenManager, + ), const ApiErrorInterceptor(), LoggingInterceptor(requestHeader: true), ]), diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index e4db9ec7..6cb6f2d9 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -31,7 +31,10 @@ dependencies: rxdart: ^0.28.0 state_notifier: ^1.0.0 stream_core: - path: /Users/renefloor/Documents/github/stream-core-flutter/packages/stream_core + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 718e2730d2380db9a61575e006fe1a89dc3e5e2a + path: packages/stream_core uuid: ^4.5.1 dev_dependencies: From 535914137735874b5807b96089768a814fec284c Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 14 Aug 2026 13:00:13 +0200 Subject: [PATCH 08/31] add ignore for git dependency --- packages/stream_feeds/pubspec.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 6cb6f2d9..17c941db 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -31,6 +31,14 @@ dependencies: rxdart: ^0.28.0 state_notifier: ^1.0.0 stream_core: + # The ignore below silences `invalid_dependency` because we occasionally + # pin stream_core_flutter to a git ref to iterate on it alongside this + # SDK between its releases. + # + # **Note:** Before publishing stream_chat_flutter, this MUST be swapped + # back to a pub version constraint — git deps are not allowed on pub.dev + # and will block the release. + # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git ref: 718e2730d2380db9a61575e006fe1a89dc3e5e2a From 97dbca94320cba7ce4265ab1e5f7c7bd31ad6367 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 14 Aug 2026 13:14:21 +0200 Subject: [PATCH 09/31] improve on private docs --- .../lib/src/client/feeds_client_impl.dart | 28 +++++++++---------- packages/stream_feeds/pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 4e472e37..e2e08e38 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -250,20 +250,20 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override late final ModerationClient moderation; - /// Builds the [TokenProvider] used to obtain a guest JWT. - /// - /// Guest users have no pre-issued token, so one is minted lazily via - /// `POST /api/v2/guest`, called through a dedicated, unauthenticated HTTP - /// client. The backend may return a different id than the one requested, to - /// avoid colliding with an existing user, so [_user] is updated to the - /// server's response to keep the WS handshake and any `client.user` reads in - /// sync with the identity the token actually authenticates as. - /// - /// Once the id is known, [_tokenManager] is swapped for one pinned to that id - /// with a static provider: the guest identity is established once (like an - /// anonymous user) rather than re-minted on every token load, and - /// `AuthInterceptor` — which reads the manager through a getter — picks up - /// the resolved id for the `user_id` query parameter. + // Builds the [TokenProvider] used to obtain a guest JWT. + // + // Guest users have no pre-issued token, so one is minted lazily via + // `POST /api/v2/guest`, called through a dedicated, unauthenticated HTTP + // client. The backend may return a different id than the one requested, to + // avoid colliding with an existing user, so [_user] is updated to the + // server's response to keep the WS handshake and any `client.user` reads in + // sync with the identity the token actually authenticates as. + // + // Once the id is known, [_tokenManager] is swapped for one pinned to that id + // with a static provider: the guest identity is established once (like an + // anonymous user) rather than re-minted on every token load, and + // `AuthInterceptor` — which reads the manager through a getter — picks up + // the resolved id for the `user_id` query parameter. TokenProvider _guestTokenProvider({ required User user, required String apiKey, diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 17c941db..b9c4d09e 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: # pin stream_core_flutter to a git ref to iterate on it alongside this # SDK between its releases. # - # **Note:** Before publishing stream_chat_flutter, this MUST be swapped + # **Note:** Before publishing stream_feeds, this MUST be swapped # back to a pub version constraint — git deps are not allowed on pub.dev # and will block the release. # ignore: invalid_dependency From 933e9b808e83c99543f3b240c4c70d2986de799d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:36:35 +0200 Subject: [PATCH 10/31] chore(deps): pin stream_core to the branch carrying the connection rework The ref this branch had predates everything the client now calls: `optionsBuilder`, `onAuthenticate(send, previousError)`, `setTokenProvider`, `usesStaticProvider`, `ConnectUserDetailsRequest.fromUser`, `DisconnectionSource.cause` and the logger. Still a git ref, so the comment above it still applies: this has to become a pub version constraint before stream_feeds is published. Co-Authored-By: Claude Opus 5 (1M context) --- melos.yaml | 2 +- packages/stream_feeds/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/melos.yaml b/melos.yaml index 68d25152..de66bc60 100644 --- a/melos.yaml +++ b/melos.yaml @@ -50,7 +50,7 @@ command: stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 718e2730d2380db9a61575e006fe1a89dc3e5e2a + ref: 25588a1b1b1541c2ee7409f68eb8076f2ad8ad81 path: packages/stream_core video_player: ^2.10.0 uuid: ^4.5.1 diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index b9c4d09e..50d9ea8b 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 718e2730d2380db9a61575e006fe1a89dc3e5e2a + ref: 25588a1b1b1541c2ee7409f68eb8076f2ad8ad81 path: packages/stream_core uuid: ^4.5.1 From faef1d943e964c88e322965def4069832a9a38c1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:36:35 +0200 Subject: [PATCH 11/31] feat(llc): adopt the reworked connection and logging APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect` takes a `connectWebSocket` flag, so a client that only makes requests can authenticate without opening a socket; an anonymous user, having no token to authenticate one with, always connects that way. A guest exchanges for its identity through the new `GuestRepository` and adopts the id the server assigns it, once per client rather than on every reconnect. `disconnect` now only closes the connection, leaving the client reusable, and the new `dispose` releases what it used to. A failed `connect` reports the cause the disconnection source carries rather than only its close reason, and a token the server refuses as expired is dropped so the next attempt loads another — or, when the provider has none to give, declines rather than presenting the same one for the life of the client. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/client/feeds_client_impl.dart | 278 ++++++++---------- .../stream_feeds/lib/src/feeds_client.dart | 123 +++++--- .../lib/src/generated_typedefs.dart | 42 +-- .../lib/src/models/feeds_config.dart | 7 + .../lib/src/repository/guest_repository.dart | 54 ++++ 5 files changed, 279 insertions(+), 225 deletions(-) create mode 100644 packages/stream_feeds/lib/src/repository/guest_repository.dart diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index e2e08e38..658650af 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -23,6 +23,7 @@ import '../repository/collections_repository.dart'; import '../repository/comments_repository.dart'; import '../repository/devices_repository.dart'; import '../repository/feeds_repository.dart'; +import '../repository/guest_repository.dart'; import '../repository/moderation_repository.dart'; import '../repository/polls_repository.dart'; import '../state/activity.dart'; @@ -61,14 +62,7 @@ import '../state/query/polls_query.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; -// Shared REST client options for both the main and guest-token HTTP clients. -BaseOptions _restApiOptions(EndpointConfig endpointConfig) => BaseOptions( - baseUrl: endpointConfig.baseFeedsUrl, - connectTimeout: const Duration(seconds: 6), - receiveTimeout: const Duration(seconds: 6), -); - -class StreamFeedsClientImpl implements StreamFeedsClient { +class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { StreamFeedsClientImpl({ required this.apiKey, required User user, @@ -80,35 +74,22 @@ class StreamFeedsClientImpl implements StreamFeedsClient { List? reconnectionPolicies, WebSocketProvider? wsProvider, api.DefaultApi? feedsRestApi, - api.DefaultApi? guestRestApi, }) : _user = user { + StreamLogger.configure(config.logConfig); + // TODO: Make this configurable const endpointConfig = EndpointConfig.production; // region Token manager setup - final userTokenProvider = switch ((user.type, tokenProvider)) { - (UserType.regular, final provider?) => provider, - (UserType.regular, null) => throw ArgumentError( - 'TokenProvider must be provided for regular users.', - ), - (UserType.anonymous, _) => TokenProvider.static( - UserToken.anonymous(userId: user.id), - ), - (UserType.guest, _) => _guestTokenProvider( - user: user, - apiKey: apiKey, - endpointConfig: endpointConfig, - guestRestApi: guestRestApi, - ), + final (userId, userTokenProvider) = switch ((user.type, tokenProvider)) { + (.regular, final provider?) => (user.id, provider), + (.regular, null) => throw ArgumentError('TokenProvider must be provided for regular users.'), + (.anonymous || .guest, _) => (User.anonymousUserId, TokenProvider.static(.anonymous())), }; - // For guest users this starts with the originally-requested id and is - // swapped for a manager carrying the server-resolved id once the token - // exchange completes (see `_guestTokenProvider`). `AuthInterceptor` reads - // the manager through a getter, so it always sees the current instance. _tokenManager = TokenManager( - userId: user.id, + userId: userId, tokenProvider: userTokenProvider, ); @@ -117,21 +98,24 @@ class StreamFeedsClientImpl implements StreamFeedsClient { // region WebSocket client setup _ws = StreamWebSocketClient( - options: WebSocketOptions( + tag: 'SF:Ws', + messageCodec: const FeedsWsCodec(), + onAuthenticate: _authenticateUser, + wsProvider: wsProvider, + optionsBuilder: () => WebSocketOptions( url: endpointConfig.wsEndpoint, queryParameters: { 'api_key': apiKey, - 'stream-auth-type': 'jwt', + // Feeds only support ws conn for jwt auth + 'stream-auth-type': AuthType.jwt.headerValue, 'X-Stream-Client': _systemEnvironmentManager.userAgent, }, ), - messageCodec: const FeedsWsCodec(), - onConnectionEstablished: _authenticateUser, - wsProvider: wsProvider, ); _connectionRecoveryHandler = ConnectionRecoveryHandler( client: _ws, + tag: 'SF:WsRecovery', retryStrategy: retryStrategy, networkStateProvider: networkStateProvider, lifecycleStateProvider: lifecycleStateProvider, @@ -153,18 +137,19 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final httpClient = StreamCoreHttpClient( - options: _restApiOptions(endpointConfig), + options: BaseOptions( + baseUrl: endpointConfig.baseFeedsUrl, + connectTimeout: const Duration(seconds: 6), + receiveTimeout: const Duration(seconds: 6), + ), ).apply( (client) => client.interceptors.addAll([ ApiKeyInterceptor(apiKey), HeadersInterceptor(_systemEnvironmentManager), - if (user.type != UserType.anonymous) connectionIdInterceptor, - AuthInterceptor.withProvider( - client, - tokenManagerProvider: () => _tokenManager, - ), + if (user.type != .anonymous) connectionIdInterceptor, + AuthInterceptor(tag: 'SF:HttpAuth', client, _tokenManager), const ApiErrorInterceptor(), - LoggingInterceptor(requestHeader: true), + LoggingInterceptor(tag: 'SF:Http'), ]), ); @@ -187,6 +172,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { _moderationRepository = ModerationRepository(feedsApi); _pollsRepository = PollsRepository(feedsApi); _capabilitiesRepository = CapabilitiesRepository(feedsApi); + _guestRepository = GuestRepository(feedsApi); moderation = ModerationClient(_moderationRepository); @@ -201,19 +187,15 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final String apiKey; - // The current user identity. Mutable because a guest user's id/profile may - // be reassigned by the server once the guest token exchange completes; see - // [_guestTokenProvider]. @override User get user => _user; User _user; final FeedsConfig config; - // Not `final`: for guest users this is swapped for a manager carrying the - // server-resolved user id once the token exchange completes (see - // `_guestTokenProvider`). - late TokenManager _tokenManager; + final _logger = const StreamLogger('SF:Client'); + + late final TokenManager _tokenManager; late final StreamWebSocketClient _ws; late final ConnectionRecoveryHandler _connectionRecoveryHandler; @@ -232,6 +214,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { late final ModerationRepository _moderationRepository; late final PollsRepository _pollsRepository; late final CapabilitiesRepository _capabilitiesRepository; + late final GuestRepository _guestRepository; // TODO: Fill this with correct values late final _systemEnvironmentManager = SystemEnvironmentManager( @@ -250,102 +233,26 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override late final ModerationClient moderation; - // Builds the [TokenProvider] used to obtain a guest JWT. - // - // Guest users have no pre-issued token, so one is minted lazily via - // `POST /api/v2/guest`, called through a dedicated, unauthenticated HTTP - // client. The backend may return a different id than the one requested, to - // avoid colliding with an existing user, so [_user] is updated to the - // server's response to keep the WS handshake and any `client.user` reads in - // sync with the identity the token actually authenticates as. - // - // Once the id is known, [_tokenManager] is swapped for one pinned to that id - // with a static provider: the guest identity is established once (like an - // anonymous user) rather than re-minted on every token load, and - // `AuthInterceptor` — which reads the manager through a getter — picks up - // the resolved id for the `user_id` query parameter. - TokenProvider _guestTokenProvider({ - required User user, - required String apiKey, - required EndpointConfig endpointConfig, - api.DefaultApi? guestRestApi, - }) { - var guestApi = guestRestApi; - - return TokenProvider.dynamic((_) async { - final guestApiClient = guestApi ??= api.DefaultApi( - StreamCoreHttpClient(options: _restApiOptions(endpointConfig)).apply( - (client) => client.interceptors.addAll([ - ApiKeyInterceptor(apiKey), - HeadersInterceptor(_systemEnvironmentManager), - const ApiErrorInterceptor(), - ]), - ), - ); - - final result = await guestApiClient.createGuest( - createGuestRequest: api.CreateGuestRequest( - user: api.UserRequest( - id: user.id, - name: user.originalName, - image: user.image, - custom: user.custom.isEmpty ? null : user.custom, - ), - ), - ); - final response = result.getOrThrow(); - - _user = User( - id: response.user.id, - name: response.user.name, - image: response.user.image, - role: response.user.role, - type: UserType.guest, - custom: response.user.custom, - ); - - final token = UserToken(response.accessToken); - - // Pin the manager to the resolved id so subsequent REST/WS calls use it - // without re-running the guest exchange. - _tokenManager = TokenManager( - userId: response.user.id, - tokenProvider: TokenProvider.static(token), - ); - - return token; - }); - } + Future _authenticateUser( + WsRequestSender send, + StreamApiError? previousError, + ) async { + if (previousError?.isTokenExpiredError ?? false) { + _tokenManager.expireToken(); - Future _authenticateUser() async { - try { - final userToken = await _tokenManager.getToken(); + if (_tokenManager.usesStaticProvider) { + throw ClientException(message: 'The token was refused and the provider has no other to give'); + } + } - final connectUserRequest = WsAuthMessageRequest( - products: const ['feeds'], - token: userToken.rawValue, - userDetails: ConnectUserDetailsRequest( - id: user.id, - name: user.originalName, - image: user.image, - custom: user.custom, - ), - ); + final userToken = await _tokenManager.getToken(); + final connectUserRequest = WsAuthMessageRequest( + products: const ['feeds'], + token: userToken.rawValue, + userDetails: .fromUser(user), + ); - _ws.send(connectUserRequest); - } catch (error) { - // Without this, a token-loading failure (e.g. the guest exchange - // failing) would leave the connection stuck in `Authenticating` - // forever, since nothing else observes this callback's Future and - // `connect()` only resolves on a `Connected`/`Disconnected` state. - // - // The default `userInitiated` source (rather than `serverInitiated`) is - // used deliberately: this is a client-side failure to obtain a token - // at all, not a retryable server condition, and `serverInitiated` is - // eligible for automatic reconnection, which would otherwise retry - // the failing token load indefinitely. - await _ws.disconnect(); - } + return send(connectUserRequest).getOrThrow(); } @override @@ -354,18 +261,76 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override EventEmitter get stateUpdateEvents => _stateUpdateEmitter; late final _stateUpdateEmitter = MutableEventEmitter(); - StreamSubscription? _wsEventToStateMapperSubscription; + late final StreamSubscription _wsEventToStateMapperSubscription; @override ConnectionStateEmitter get connectionState => _ws.connectionState; @override - Future connect() async { - if (user.type == UserType.anonymous) { - throw ClientException(message: 'Cannot connect as an anonymous user.'); + Future connect({ + bool connectWebSocket = true, + }) { + if (isDisposed) { + throw StateError('Client for ${user.id} has been disposed'); + } + + if (connectionState.value case Connecting() || Authenticating()) { + throw ClientException(message: 'Connection already in progress for ${user.id}'); + } + + if (connectionState.value case Connected()) { + throw ClientException(message: 'Connection already available for ${user.id}'); + } + + _logger.d(() => 'connect ${user.id} (${user.type.name}), webSocket: $connectWebSocket'); + + return switch (user.type) { + .guest => _connectGuestUser(connectWebSocket: connectWebSocket), + .regular || .anonymous => _connectUser(connectWebSocket: connectWebSocket), + }; + } + + Future _connectGuestUser({ + required bool connectWebSocket, + }) async { + assert(user.type == .guest, 'Can only connect as a guest user'); + + // Every exchange creates another guest, so one already established is kept. + if (_tokenManager.userId != user.id) { + final result = await _guestRepository.createGuest(user); + + // Reported like every other connect failure, with the cause attached. + final response = result.getOrElse( + (error, stackTrace) => throw ClientException( + message: 'Failed to create a guest user', + error: error, + stackTrace: stackTrace, + ), + ); + + final tokenProvider = TokenProvider.static(response.token); + + // The server assigns the id, so adopt it and authenticate as it. + _logger.d(() => 'guest created, server assigned ${response.user.id}'); + _user = response.user; + _tokenManager.setTokenProvider( + response.user.id, + tokenProvider: tokenProvider, + ); + } + + return _connectUser(connectWebSocket: connectWebSocket); + } + + Future _connectUser({ + required bool connectWebSocket, + }) async { + // An anonymous user has no token to authenticate a socket with. + if (!connectWebSocket || user.type == .anonymous) { + _logger.d(() => 'connected ${user.id} without a socket'); + return; } - // Connect to the WebSocket _ws.connect().ignore(); final state = await Future.any([ @@ -373,19 +338,30 @@ class StreamFeedsClientImpl implements StreamFeedsClient { connectionState.waitFor(), ]); - if (state is Disconnected) { - final message = state.source.closeReason; - throw ClientException(message: message); + if (state case Disconnected(:final source)) { + _logger.w(() => 'connect ${user.id} failed: ${source.closeReason}', error: source.cause); + throw ClientException(message: source.closeReason, error: source.cause); } + + _logger.d(() => 'connected ${user.id}'); } @override - Future disconnect() async { - await _wsEventToStateMapperSubscription?.cancel(); + Future disconnect() => _ws.disconnect(); + + @override + Future dispose() async { + if (isDisposed) return; + + await _wsEventToStateMapperSubscription.cancel(); await _stateUpdateEmitter.close(); await _connectionRecoveryHandler.dispose(); - await _ws.disconnect(); + await _ws.dispose(); + + _capabilitiesRepository.dispose(); + + return super.dispose(); } @override diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index e169b664..967b984a 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -65,10 +65,10 @@ export 'client/moderation_client.dart'; /// user: User( /// id: 'user-123', /// name: 'John Doe', -/// imageUrl: 'https://example.com/avatar.jpg', -/// customData: {'email': 'john@example.com'}, +/// image: 'https://example.com/avatar.jpg', +/// custom: {'email': 'john@example.com'}, /// ), -/// userTokenProvider: UserTokenProvider.static('user-jwt-token-here'), +/// tokenProvider: TokenProvider.static(UserToken('user-jwt-token-here')), /// config: FeedsConfig( /// // Optional configuration /// ), @@ -78,6 +78,25 @@ export 'client/moderation_client.dart'; /// await client.connect(); /// ``` /// +/// ## Logging +/// +/// Pass `logPriority` to see what the client is doing, and `logHandler` to say where those +/// records go. Left alone, the client writes nothing: +/// +/// ```dart +/// final client = StreamFeedsClient( +/// apiKey: 'your-api-key', +/// user: user, +/// logPriority: StreamLogPriority.debug, +/// ); +/// ``` +/// +/// Both settings are shared with every other Stream SDK in the process, so passing them decides +/// for those too. Every record this client writes is tagged `SF:`, which is what tells them apart +/// from another SDK's in the same log. +/// +/// Some of those records carry the `Authorization` header, so weigh what reads them. +/// /// ### Different User Types /// /// The [User] class supports different authentication types: @@ -87,9 +106,8 @@ export 'client/moderation_client.dart'; /// final regularUser = User( /// id: 'user-123', /// name: 'John Doe', -/// imageUrl: 'https://example.com/avatar.jpg', -/// role: 'admin', -/// customData: {'department': 'Engineering'}, +/// image: 'https://example.com/avatar.jpg', +/// custom: {'department': 'Engineering'}, /// ); /// /// // Guest user (temporary access) @@ -105,16 +123,16 @@ export 'client/moderation_client.dart'; /// /// ```dart /// // Static token (for development or long-lived tokens) -/// final staticProvider = UserTokenProvider.static('your-jwt-token'); +/// final staticProvider = TokenProvider.static(UserToken('your-jwt-token')); /// /// // Dynamic token (for refreshable tokens or secure storage) -/// final dynamicProvider = UserTokenProvider.dynamic(() async { -/// // Fetch from secure storage, API, etc. -/// final token = await secureStorage.read(key: 'user_token'); +/// final dynamicProvider = TokenProvider.dynamic((userId) async { +/// // Fetch from secure storage, an endpoint of your own, etc. +/// final token = await secureStorage.read(key: 'token_for_\$userId'); /// if (token == null) { /// throw Exception('No token available'); /// } -/// return token; +/// return UserToken(token); /// }); /// ``` /// @@ -133,17 +151,17 @@ export 'client/moderation_client.dart'; abstract interface class StreamFeedsClient { /// Creates a new Stream Feeds client instance. /// - /// The [apiKey] should be obtained from your Stream dashboard and the [user] contains - /// authentication and profile information. The [config] parameter allows for - /// customizing client behavior such as timeouts, logging, and network settings. + /// The [apiKey] comes from your Stream dashboard. A [tokenProvider] is required for a + /// regular user, and must be omitted for a guest or anonymous one, whose token the client + /// obtains itself. See [FeedsConfig] for what [config] carries. /// /// Example: /// ```dart /// final user = User( /// id: 'user-123', /// name: 'John Doe', - /// imageUrl: 'https://example.com/avatar.jpg', - /// customData: {'email': 'john@example.com'}, + /// image: 'https://example.com/avatar.jpg', + /// custom: {'email': 'john@example.com'}, /// ); /// /// final token = UserToken('jwt-token-here'); @@ -151,11 +169,7 @@ abstract interface class StreamFeedsClient { /// final client = StreamFeedsClient( /// apiKey: 'your-api-key-here', /// user: user, - /// userTokenProvider: UserTokenProvider.static(token), - /// config: FeedsConfig( - /// timeout: Duration(seconds: 30), - /// enableLogging: true, - /// ), + /// tokenProvider: TokenProvider.static(token), /// ); /// ``` factory StreamFeedsClient({ @@ -169,9 +183,12 @@ abstract interface class StreamFeedsClient { List? reconnectionPolicies, @visibleForTesting WebSocketProvider? wsProvider, @visibleForTesting api.DefaultApi? feedsRestApi, - @visibleForTesting api.DefaultApi? guestRestApi, }) = StreamFeedsClientImpl; + /// The user this client is authenticated as. + /// + /// A guest user is given its id by the server, so once connected this is the user the server + /// returned rather than the one the client was created with. User get user; /// The event emitter for listening to client events. @@ -229,25 +246,47 @@ abstract interface class StreamFeedsClient { /// Establishes a connection to the Stream service. /// - /// Sets up authentication and initializes the WebSocket connection for real-time - /// updates. This method should be called before using any other client functionality. + /// Call this before anything else on the client. + /// + /// Throws a [ClientException] if the connection fails, or if one is already established or in + /// progress, and a [StateError] once [dispose] has been called. + /// + /// Pass [connectWebSocket] as `false` for a client that only makes requests: no events arrive + /// and watching is rejected. An anonymous user always connects this way, having no token for a + /// socket. + Future connect({ + bool connectWebSocket = true, + }); + + /// Disconnects the current client. + /// + /// Closes the WebSocket connection and leaves the client ready to be used again. + /// Subscriptions to [events], [stateUpdateEvents] and [connectionState] keep + /// working, so they do not have to be set up again after reconnecting. /// /// Example: /// ```dart - /// try { - /// await client.connect(); - /// print('Connected successfully'); - /// } catch (e) { - /// print('Connection failed: $e'); - /// } + /// await client.disconnect(); + /// + /// // The same client, and the same subscriptions, can be reconnected later. + /// await client.connect(); /// ``` - Future connect(); - - /// Disconnects the current client. /// - /// Closes the WebSocket connection and cleans up all resources. + /// See [dispose] for when the client is no longer needed at all. Future disconnect(); + /// Releases every resource held by the client. + /// + /// Unlike [disconnect], this cannot be undone: [connect] throws afterwards and + /// [stateUpdateEvents] stops emitting. Call it once the client is no longer + /// needed, such as when the user signs out. Calling it again does nothing. + /// + /// Example: + /// ```dart + /// await client.dispose(); + /// ``` + Future dispose(); + /// Creates a feed instance from the provided [query]. /// /// Creates a [Feed] object using a [FeedQuery] that can include additional @@ -657,7 +696,7 @@ abstract interface class StreamFeedsClient { /// ```dart /// final result = await client.getApp(); /// switch (result) { - /// case Success(value: final appData): + /// case Success(data: final appData): /// print('App name: ${appData.name}'); /// print('File upload size limit: ${appData.fileUploadConfig.sizeLimit}'); /// case Failure(error: final error): @@ -679,7 +718,7 @@ abstract interface class StreamFeedsClient { /// final result = await client.queryDevices(); /// /// switch (result) { - /// case Success(value: final devicesResponse): + /// case Success(data: final devicesResponse): /// print('Found ${devicesResponse.devices.length} devices'); /// case Failure(error: final error): /// print('Failed to query devices: $error'); @@ -805,7 +844,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final batchFollowData): + /// case Success(data: final batchFollowData): /// print('Created ${batchFollowData.created.length} new follows'); /// print('Total follows: ${batchFollowData.follows.length}'); /// case Failure(error: final error): @@ -841,7 +880,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final unfollowedFollows): + /// case Success(data: final unfollowedFollows): /// print('Unfollowed ${unfollowedFollows.length} feeds'); /// case Failure(error: final error): /// print('Failed to unfollow feeds: $error'); @@ -864,7 +903,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Found ${response.collections.length} collections'); /// case Failure(error: final error): /// print('Failed to read collections: $error'); @@ -896,7 +935,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Created ${response.collections.length} collections'); /// case Failure(error: final error): /// print('Failed to create collections: $error'); @@ -927,7 +966,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Updated ${response.collections.length} collections'); /// case Failure(error: final error): /// print('Failed to update collections: $error'); @@ -950,7 +989,7 @@ abstract interface class StreamFeedsClient { /// ); /// /// switch (result) { - /// case Success(value: final response): + /// case Success(data: final response): /// print('Deleted collections successfully'); /// case Failure(error: final error): /// print('Failed to delete collections: $error'); diff --git a/packages/stream_feeds/lib/src/generated_typedefs.dart b/packages/stream_feeds/lib/src/generated_typedefs.dart index 680ab9d1..3e94b90d 100644 --- a/packages/stream_feeds/lib/src/generated_typedefs.dart +++ b/packages/stream_feeds/lib/src/generated_typedefs.dart @@ -44,10 +44,7 @@ typedef PollVote = PollVoteResponseData; typedef BanActionRequest = BanActionRequestPayload; /// Use [BanActionRequestPayloadDeleteMessages] instead. -@Deprecated( - 'Renamed to BanActionRequestPayloadDeleteMessages. ' - 'Migrate to BanActionRequestPayloadDeleteMessages.', -) +@Deprecated('Renamed to BanActionRequestPayloadDeleteMessages. Migrate to BanActionRequestPayloadDeleteMessages.') typedef BanActionRequestDeleteMessages = BanActionRequestPayloadDeleteMessages; /// Use [BlockActionRequestPayload] instead. @@ -55,10 +52,7 @@ typedef BanActionRequestDeleteMessages = BanActionRequestPayloadDeleteMessages; typedef BlockActionRequest = BlockActionRequestPayload; /// Use [ShadowBlockActionRequestPayload] instead. -@Deprecated( - 'Renamed to ShadowBlockActionRequestPayload. ' - 'Migrate to ShadowBlockActionRequestPayload.', -) +@Deprecated('Renamed to ShadowBlockActionRequestPayload. Migrate to ShadowBlockActionRequestPayload.') typedef ShadowBlockActionRequest = ShadowBlockActionRequestPayload; /// Use [CustomActionRequestPayload] instead. @@ -70,45 +64,31 @@ typedef CustomActionRequest = CustomActionRequestPayload; typedef DeleteUserRequest = DeleteUserRequestPayload; /// Use [DeleteActivityRequestPayload] instead. -@Deprecated( - 'Renamed to DeleteActivityRequestPayload. Migrate to DeleteActivityRequestPayload.', -) +@Deprecated('Renamed to DeleteActivityRequestPayload. Migrate to DeleteActivityRequestPayload.') typedef DeleteActivityRequest = DeleteActivityRequestPayload; /// Use [DeleteCommentRequestPayload] instead. -@Deprecated( - 'Renamed to DeleteCommentRequestPayload. Migrate to DeleteCommentRequestPayload.', -) +@Deprecated('Renamed to DeleteCommentRequestPayload. Migrate to DeleteCommentRequestPayload.') typedef DeleteCommentRequest = DeleteCommentRequestPayload; /// Use [DeleteReactionRequestPayload] instead. -@Deprecated( - 'Renamed to DeleteReactionRequestPayload. Migrate to DeleteReactionRequestPayload.', -) +@Deprecated('Renamed to DeleteReactionRequestPayload. Migrate to DeleteReactionRequestPayload.') typedef DeleteReactionRequest = DeleteReactionRequestPayload; /// Use [DeleteMessageRequestPayload] instead. -@Deprecated( - 'Renamed to DeleteMessageRequestPayload. Migrate to DeleteMessageRequestPayload.', -) +@Deprecated('Renamed to DeleteMessageRequestPayload. Migrate to DeleteMessageRequestPayload.') typedef DeleteMessageRequest = DeleteMessageRequestPayload; /// Use [MarkReviewedRequestPayload] instead. -@Deprecated( - 'Renamed to MarkReviewedRequestPayload. Migrate to MarkReviewedRequestPayload.', -) +@Deprecated('Renamed to MarkReviewedRequestPayload. Migrate to MarkReviewedRequestPayload.') typedef MarkReviewedRequest = MarkReviewedRequestPayload; /// Use [RejectAppealRequestPayload] instead. -@Deprecated( - 'Renamed to RejectAppealRequestPayload. Migrate to RejectAppealRequestPayload.', -) +@Deprecated('Renamed to RejectAppealRequestPayload. Migrate to RejectAppealRequestPayload.') typedef RejectAppealRequest = RejectAppealRequestPayload; /// Use [RestoreActionRequestPayload] instead. -@Deprecated( - 'Renamed to RestoreActionRequestPayload. Migrate to RestoreActionRequestPayload.', -) +@Deprecated('Renamed to RestoreActionRequestPayload. Migrate to RestoreActionRequestPayload.') typedef RestoreActionRequest = RestoreActionRequestPayload; /// Use [UnbanActionRequestPayload] instead. @@ -116,7 +96,5 @@ typedef RestoreActionRequest = RestoreActionRequestPayload; typedef UnbanActionRequest = UnbanActionRequestPayload; /// Use [UnblockActionRequestPayload] instead. -@Deprecated( - 'Renamed to UnblockActionRequestPayload. Migrate to UnblockActionRequestPayload.', -) +@Deprecated('Renamed to UnblockActionRequestPayload. Migrate to UnblockActionRequestPayload.') typedef UnblockActionRequest = UnblockActionRequestPayload; diff --git a/packages/stream_feeds/lib/src/models/feeds_config.dart b/packages/stream_feeds/lib/src/models/feeds_config.dart index 4b41d674..2ead1f26 100644 --- a/packages/stream_feeds/lib/src/models/feeds_config.dart +++ b/packages/stream_feeds/lib/src/models/feeds_config.dart @@ -10,9 +10,16 @@ class FeedsConfig { const FeedsConfig({ this.cdnClient, this.pushNotificationsConfig, + this.logConfig, }); final CdnClient? cdnClient; final PushNotificationsConfig? pushNotificationsConfig; + + /// How much the client reports, and where those records go. + /// + /// Left out, the client touches no logger at all — the one every Stream SDK in the process + /// shares stays as whatever configured it, or silent if nothing did. + final StreamLogConfig? logConfig; } diff --git a/packages/stream_feeds/lib/src/repository/guest_repository.dart b/packages/stream_feeds/lib/src/repository/guest_repository.dart new file mode 100644 index 00000000..bbacea6c --- /dev/null +++ b/packages/stream_feeds/lib/src/repository/guest_repository.dart @@ -0,0 +1,54 @@ +import 'package:stream_core/stream_core.dart'; + +import '../generated/api/api.dart' as api; + +/// Repository for creating guest users. +/// +/// Provides guest creation, which issues a guest identity along with the token that +/// authenticates it. +/// +/// All methods return [Result] objects for explicit error handling. +class GuestRepository { + /// Creates a new [GuestRepository] instance. + /// + /// The [api] parameter is required for making API calls to the Stream Feeds service. + const GuestRepository(this._api); + + // The API client used for making requests to the Stream Feeds service. + final api.DefaultApi _api; + + /// Creates a guest user, returning the identity and the token that authenticates it. + /// + /// The returned user is not [requested]: its id is assigned rather than accepted, and its role + /// is always `guest`. Every call creates another guest, so call it once and keep the result. + /// + /// Returns a [Result] containing the guest user and its token, or an error. + Future> createGuest( + User requested, + ) async { + final result = await _api.createGuest( + createGuestRequest: api.CreateGuestRequest( + user: api.UserRequest( + id: requested.id, + name: requested.originalName, + image: requested.image, + custom: requested.custom.takeIf((it) => it.isNotEmpty), + ), + ), + ); + + return result.mapCatching((response) { + final user = User( + id: response.user.id, + name: response.user.name, + image: response.user.image, + role: response.user.role, + type: UserType.guest, + custom: response.user.custom, + teams: response.user.teams, + ); + + return (user: user, token: UserToken(response.accessToken)); + }); + } +} From a5582c7baec58e2f3037d0f4975f6017c7386b4c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:36:48 +0200 Subject: [PATCH 12/31] test(llc): cover connect, disconnect and the failures around them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distinction `dispose` introduced had no test: `disconnect` now leaves `events` and `stateUpdateEvents` open so a client can be used again, and only `dispose` closes them. Anonymous users had no test at all, despite the client promising they connect without a socket. Also covers the two failures nothing reached before — a token the provider could not issue, and an authentication frame that could not be sent, which used to sit in `Authenticating` until the connect timeout swept it up. `mockFailedSend` on the tester is what reaches the second. Every one of these was checked by breaking the code it covers and confirming it fails. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/feeds_client_logging_test.dart | 126 +++++ .../test/client/feeds_client_test.dart | 521 +++++++++++++++++- .../state/activity_comment_list_test.dart | 12 +- .../test/state/comment_reply_list_test.dart | 62 +-- .../lib/src/testers/base_tester.dart | 30 +- .../lib/src/testers/feeds_client_tester.dart | 2 + .../lib/src/testers/websocket_tester.dart | 56 +- 7 files changed, 726 insertions(+), 83 deletions(-) create mode 100644 packages/stream_feeds/test/client/feeds_client_logging_test.dart diff --git a/packages/stream_feeds/test/client/feeds_client_logging_test.dart b/packages/stream_feeds/test/client/feeds_client_logging_test.dart new file mode 100644 index 00000000..3eca5b5d --- /dev/null +++ b/packages/stream_feeds/test/client/feeds_client_logging_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_feeds_test/stream_feeds_test.dart'; + +/// Keeps every record, so a test can see what the client reported. +final class _RecordingLogHandler extends StreamLogHandler { + final records = []; + + Iterable get tags => records.map((it) => it.tag); + + Iterable get messages => records.map((it) => it.message); + + @override + void handle(StreamLogRecord record) => records.add(record); +} + +StreamFeedsClient _client({StreamLogConfig? logConfig}) { + return StreamFeedsClient( + apiKey: 'apiKey', + user: const User(id: 'luke_skywalker'), + tokenProvider: TokenProvider.static(generateTestUserToken('luke_skywalker')), + config: FeedsConfig(logConfig: logConfig), + ); +} + +List capturePrints(void Function() body) { + final lines = []; + runZoned(body, zoneSpecification: ZoneSpecification(print: (_, _, _, String l) => lines.add(l))); + return lines; +} + +void main() { + group('logging', () { + final handler = _RecordingLogHandler(); + + feedsClientTest( + 'reports what the client does under its own tags', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + setUp: (_) { + handler.records.clear(); + StreamLogger.handler = handler; + StreamLogger.priority = StreamLogPriority.verbose; + }, + tearDown: (_) => StreamLogger.reset(), + body: (tester) async { + await tester.client.connect(); + addTearDown(tester.client.disconnect); + + // An app running two Stream SDKs shares one handler, so every record this client + // produces has to say which SDK it came from. + expect(handler.tags, isNotEmpty); + expect(handler.tags, everyElement(startsWith('SF:'))); + }, + ); + + feedsClientTest( + 'writes nothing when no handler is installed', + connect: (tester) => tester.mockSuccessfulAuth(tester.user.id), + body: (tester) async { + final printed = []; + await runZoned( + () async { + await tester.client.connect(); + addTearDown(tester.client.disconnect); + }, + zoneSpecification: ZoneSpecification(print: (_, _, _, String line) => printed.add(line)), + ); + + expect(printed, isEmpty); + }, + ); + + test('installs the handler and priority the app asked for', () { + final mine = _RecordingLogHandler(); + addTearDown(StreamLogger.reset); + + addTearDown( + _client( + logConfig: StreamLogConfig(priority: StreamLogPriority.debug, handler: mine), + ).dispose, + ); + + // What the client is answerable for is the installation; the records themselves come from + // the components, which report under their own tags. + const StreamLogger('SF:Probe').d(() => 'reached the handler the app passed'); + + expect(mine.messages, ['reached the handler the app passed']); + }); + + test('leaves the logger alone when the app configured no logging', () { + final installed = _RecordingLogHandler(); + StreamLogger.handler = installed; + StreamLogger.priority = StreamLogPriority.verbose; + addTearDown(StreamLogger.reset); + + addTearDown(_client().dispose); + + const StreamLogger('SV:Call').d(() => 'another SDK, still heard'); + + // Constructing a client must not decide logging for the app, or for an SDK beside it. + expect(installed.messages, ['another SDK, still heard']); + }); + + test('keeps writing to the console when an app composes with the default handler', () { + final mine = _RecordingLogHandler(); + addTearDown( + _client( + logConfig: StreamLogConfig( + priority: StreamLogPriority.debug, + handler: StreamLogHandler.composite([ + StreamLogConfig.defaultHandler, + mine, + ]), + ), + ).dispose, + ); + + final printed = capturePrints(() => const StreamLogger('SF:Probe').d(() => 'to both')); + + // Naming a handler of your own should not cost you the one the client would have used. + expect(printed.single, contains('to both')); + expect(mine.messages, ['to both']); + }); + }); +} diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index beb723ce..d97630db 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -48,18 +48,317 @@ void main() { ); // Attempt connection - should fail + await expectLater(tester.client.connect(), throwsA(isA())); + + // Verify state transitions expectation + await connectionStateExpectation; + }, + ); + + feedsClientTest( + 'should not open a WebSocket when asked not to', + connect: (tester) => addTearDown(tester.client.dispose), + body: (tester) async { + await tester.client.connect(connectWebSocket: false); + + // Verify no socket was opened + expect(tester.client.connectionState.value, isA()); + }, + ); + }); + + group('token rejected by the server', () { + var tokenLoads = 0; + + Object connectionError() => { + 'type': 'connection.error', + 'connection_id': 'test-connection-id', + 'created_at': DateTime.timestamp().millisecondsSinceEpoch, + 'error': { + // 40 = the token expired, the one refusal another token repairs. + 'code': 40, + 'message': 'token expired', + 'StatusCode': 401, + 'details': [], + 'duration': '0ms', + 'more_info': '', + }, + }; + + feedsClientTest( + 'stays closed when the provider has no other token to give', + connect: (tester) async { + // The default provider is static, as a guest's is. + tester.mockSuccessfulAuth(tester.user.id); + await tester.client.connect(); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + final states = []; + final subscription = tester.client.connectionState.listen(states.add); + addTearDown(subscription.cancel); + + await tester.emitEvent(connectionError()); + await tester.pumpEventQueue(); + + // Reconnecting presents the token again, so the attempt is declined rather than made — and + // an authentication failure is not retried, which is what stops the backoff from offering + // the refused token for the life of the client. + expect( + tester.client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + expect(states.whereType(), hasLength(1)); + }, + ); + + feedsClientTest( + 'fails the connection with the error the server sent', + connect: (tester) { + tester.mockFailedAuth(); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + await expectLater( + tester.client.connect(), + throwsA(isA().having((it) => it.apiError?.code, 'apiError.code', 40)), + ); + }, + ); + + feedsClientTest( + 'is kept when the server closed for a reason other than the token', + tokenProvider: TokenProvider.dynamic((userId) async { + tokenLoads++; + return generateTestUserToken(userId); + }), + connect: (tester) async { + tester.mockSuccessfulAuth(tester.user.id); + await tester.client.connect(); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + tokenLoads = 0; + + // A server error that says nothing about the token, and is retried like + // any other. Dropping the token here would send the reconnect to the + // provider for one that was never refused. + await tester.emitEvent({ + 'type': 'connection.error', + 'connection_id': 'test-connection-id', + 'created_at': DateTime.timestamp().millisecondsSinceEpoch, + 'error': { + 'code': 5, // internal error + 'message': 'something went wrong', + 'StatusCode': 500, + 'details': [], + 'duration': '0ms', + 'more_info': '', + }, + }); + await tester.pumpEventQueue(); + + expect(tester.client.connectionState.value, isA()); + expect(tokenLoads, 0); + }, + ); + + feedsClientTest( + 'is kept when a disconnect was not about it', + tokenProvider: TokenProvider.dynamic((userId) async { + tokenLoads++; + return generateTestUserToken(userId); + }), + connect: (tester) async { + tester.mockSuccessfulAuth(tester.user.id); + await tester.client.connect(); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + tokenLoads = 0; + + // A deliberate disconnect says nothing about the token, and neither does + // a network drop. Dropping it here would send every reconnect to the + // provider for a token that was never refused. + await tester.client.disconnect(); + await tester.client.connect(); + + expect(tester.client.connectionState.value, isA()); + expect(tokenLoads, 0); + }, + ); + + feedsClientTest( + 'is dropped on a live connection, which comes back with a fresh one', + tokenProvider: TokenProvider.dynamic((userId) async { + tokenLoads++; + return generateTestUserToken(userId); + }), + connect: (tester) async { + tester.mockSuccessfulAuth(tester.user.id); + await tester.client.connect(); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + tokenLoads = 0; + final states = []; + final subscription = tester.client.connectionState.listen(states.add); + addTearDown(subscription.cancel); + + // The server refuses the token of a connection that was working. These + // test tokens name no expiry, so nothing could have seen it coming — + // which is the case the client drops the cached token for. + await tester.emitEvent(connectionError()); + await tester.pumpEventQueue(); + + // Reconnecting is the recovery handler's, and the token it presents was + // issued after the refusal. The app is told nothing and does nothing. + expect(states.whereType(), hasLength(1)); + expect(tester.client.connectionState.value, isA()); + expect(tokenLoads, 1); + }, + ); + }); + + group('credentials that never reached the server', () { + feedsClientTest( + 'fails the connection when the frame carrying them could not be sent', + connect: (tester) { + tester.mockFailedSend(); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + // Ignored, this would sit in `Authenticating` until the connect timeout swept it up. await expectLater( tester.client.connect(), throwsA(isA()), ); - // Verify state transitions expectation - await connectionStateExpectation; + expect( + tester.client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); + }); + + group('token the provider could not issue', () { + feedsClientTest( + 'fails the connection with the reason it could not be loaded', + tokenProvider: TokenProvider.dynamic((_) async => throw Exception('token endpoint is down')), + connect: (tester) { + // Stubbed so the socket itself works: the only thing failing here is the token. + tester.mockSuccessfulAuth(tester.user.id); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + // Nothing was refused: the token was never made, so the frame never went out. + await expectLater( + tester.client.connect(), + throwsA( + isA().having( + (it) => it.underlyingError, + 'cause', + isA().having((it) => '$it', 'message', contains('token endpoint is down')), + ), + ), + ); + + expect( + tester.client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); }, ); }); group('disconnect', () { + feedsClientTest( + 'should connect again, still emitting to a subscription taken before', + connect: (tester) => addTearDown(tester.client.dispose), + body: (tester) async { + // Subscribed before the first connect, and never renewed: a reconnect + // that rebuilds the event pipeline would leave this listener silent. + final events = []; + final subscription = tester.client.stateUpdateEvents.listen(events.add); + addTearDown(subscription.cancel); + + tester.mockSuccessfulAuth(tester.user.id); + await tester.client.connect(); + await tester.client.disconnect(); + await tester.client.connect(); + + expect(tester.client.connectionState.value, isA()); + + await tester.emitEvent( + FeedDeletedEvent( + type: EventTypes.feedDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + ), + ); + + expect(events, isNotEmpty); + }, + ); + + feedsClientTest( + 'should refuse to connect once disposed', + connect: (tester) async { + tester.mockSuccessfulAuth(tester.user.id); + await tester.client.connect(); + }, + body: (tester) async { + await tester.client.dispose(); + + // Matched on the message because a closed emitter raises a `StateError` of its own + // further in, which would satisfy the type alone. + expect( + () => tester.client.connect(), + throwsA(isA().having((it) => it.message, 'message', contains('has been disposed'))), + ); + + // Disposing twice is a no-op rather than an error. + await expectLater(tester.client.dispose(), completes); + }, + ); + + feedsClientTest( + 'should refuse to connect when a connection is already established', + body: (tester) { + // Told they asked for something they already have, rather than silently doing nothing. + expect( + () => tester.client.connect(), + throwsA(isA().having((it) => it.message, 'message', contains('already available'))), + ); + + // The connection it already had is left alone. + expect(tester.client.connectionState.value, isA()); + }, + ); + + feedsClientTest( + 'should refuse to connect while a connection is still being established', + connect: (tester) { + tester.mockSuccessfulAuth(tester.user.id); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + final connecting = tester.client.connect(); + expect(tester.client.connectionState.value, isA()); + + expect( + () => tester.client.connect(), + throwsA(isA().having((it) => it.message, 'message', contains('already in progress'))), + ); + + // The attempt already under way is the one that completes. + await connecting; + expect(tester.client.connectionState.value, isA()); + }, + ); + feedsClientTest( 'should disconnect successfully', body: (tester) async { @@ -80,6 +379,82 @@ void main() { await connectionStateExpectation; }, ); + + feedsClientTest( + 'leaves the emitters open where disposing closes them', + body: (tester) async { + await tester.client.disconnect(); + + // A disconnected client is meant to be used again, so what a caller subscribed to + // has to outlive the connection. + expect(tester.client.events.isClosed, isFalse); + expect(tester.client.stateUpdateEvents.isClosed, isFalse); + + await tester.client.dispose(); + + expect(tester.client.events.isClosed, isTrue); + expect(tester.client.stateUpdateEvents.isClosed, isTrue); + }, + ); + + feedsClientTest( + 'closes the connection it is holding when disposed', + body: (tester) async { + expect(tester.client.connectionState.value, isA()); + + await tester.client.dispose(); + + // Nothing is left holding the socket open once its client is gone. + expect(tester.client.connectionState.value, isA()); + }, + ); + + feedsClientTest( + 'is a no-op on a client that never connected', + connect: (tester) => addTearDown(tester.client.dispose), + body: (tester) async { + await expectLater(tester.client.disconnect(), completes); + + // Nothing was ever opened, so there is no closure to report. + expect(tester.client.connectionState.value, isA()); + }, + ); + + feedsClientTest( + 'closes again when asked to on a connection already down', + body: (tester) async { + await tester.client.disconnect(); + + final states = []; + final subscription = tester.client.connectionState.listen(states.add); + addTearDown(subscription.cancel); + + await tester.client.disconnect(); + + // A close the caller asked for calls off a reconnection waiting to be made, so it + // has to land even on a connection already down. + expect(states.whereType(), hasLength(1)); + expect(tester.client.connectionState.value, isA()); + }, + ); + + feedsClientTest( + 'fails a connection that was still being established', + connect: (tester) { + tester.mockSuccessfulAuth(tester.user.id); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + final connecting = tester.client.connect(); + expect(tester.client.connectionState.value, isA()); + + await tester.client.disconnect(); + + // Reported, rather than left waiting on a connection no longer coming. + await expectLater(connecting, throwsA(isA())); + expect(tester.client.connectionState.value, isA()); + }, + ); }); // ============================================================ @@ -940,6 +1315,34 @@ void main() { // FEATURE: Guest User Authentication // ============================================================ + group('connect as anonymous user', () { + feedsClientTest( + 'opens no WebSocket, even when one is asked for', + user: const User.anonymous(), + connect: (tester) => addTearDown(tester.client.dispose), + body: (tester) async { + // An anonymous user has no token to authenticate a socket with, so asking for + // one cannot produce it. Requests carry their own credentials regardless. + // + // ignore: avoid_redundant_argument_values, asking explicitly is what is tested + await tester.client.connect(connectWebSocket: true); + + expect(tester.client.connectionState.value, isA()); + expect(tester.client.user.id, User.anonymousUserId); + }, + ); + + feedsClientTest( + 'can be disposed without ever having connected', + user: const User.anonymous(), + connect: (_) {}, + body: (tester) async { + // Nothing was ever subscribed or opened, so there is nothing to release. + await expectLater(tester.client.dispose(), completes); + }, + ); + }); + group('connect as guest user', () { feedsClientTest( 'should connect a guest user using the createGuest token flow', @@ -951,9 +1354,7 @@ void main() { user: UserRequest(id: 'guest-123'), ), ), - // The backend may reassign the id to avoid colliding with an - // existing user, so the mocked response intentionally differs - // from the requested id. + // The server may assign another id, so the mock differs from the request result: CreateGuestResponse( accessToken: generateTestUserToken('guest-123-xyz').rawValue, duration: '10ms', @@ -965,7 +1366,7 @@ void main() { ); tester.mockSuccessfulAuth('guest-123-xyz'); await tester.client.connect(); - addTearDown(tester.client.disconnect); + addTearDown(tester.client.dispose); }, body: (tester) { expect( @@ -973,8 +1374,7 @@ void main() { isA(), ); - // The client's exposed identity should be reconciled with the - // server-assigned guest user, not the originally-requested id. + // Verify the server-assigned identity is adopted expect(tester.client.user.id, 'guest-123-xyz'); expect(tester.client.user.type, UserType.guest); }, @@ -984,10 +1384,6 @@ void main() { 'should fail to connect a guest user when the createGuest call fails', user: const User.guest('guest-123'), connect: (tester) { - // Wires up the WebSocket mock so the connection can open; the auth - // handshake it configures is never reached since createGuest fails - // before a WsAuthMessageRequest is ever sent. - tester.mockSuccessfulAuth('guest-123'); tester.mockApiFailure( (api) => api.createGuest( createGuestRequest: const CreateGuestRequest( @@ -996,25 +1392,100 @@ void main() { ), error: Exception('Failed to create guest'), ); + addTearDown(tester.client.dispose); }, body: (tester) async { - final connectionStateExpectation = expectLater( - tester.client.connectionState, - emitsInOrder([ - isA(), - isA(), - isA(), - isA(), - isA(), - ]), - ); - await expectLater( tester.client.connect(), - throwsA(isA()), + throwsA( + isA() + .having((it) => it.message, 'message', 'Failed to create a guest user') + .having((it) => it.underlyingError, 'cause', isException), + ), ); - await connectionStateExpectation; + // Verify no socket was opened + expect(tester.client.connectionState.value, isA()); + + // Verify the requested identity is kept + expect(tester.client.user.id, 'guest-123'); + }, + ); + + feedsClientTest( + 'should create a guest user without opening a WebSocket when asked not to', + user: const User.guest('guest-123'), + connect: (tester) { + tester.mockApi( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest(user: UserRequest(id: 'guest-123')), + ), + result: CreateGuestResponse( + accessToken: generateTestUserToken('guest-123-xyz').rawValue, + duration: '10ms', + user: createDefaultUserResponse(id: 'guest-123-xyz', role: 'guest'), + ), + ); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + await tester.client.connect(connectWebSocket: false); + + // Verify the guest identity is adopted without a socket + expect(tester.client.user.id, 'guest-123-xyz'); + expect(tester.client.connectionState.value, isA()); + + tester.verifyApi( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest(user: UserRequest(id: 'guest-123')), + ), + ); + + // Opening a socket afterwards keeps that identity: the exchange runs once, even though this + // client never authenticated one. + tester.mockSuccessfulAuth('guest-123-xyz'); + await tester.client.connect(); + + expect(tester.client.connectionState.value, isA()); + // A second exchange would ask with the adopted profile, which nothing here answers, so this + // connect would fail rather than quietly mint another guest. + expect(tester.client.user.id, 'guest-123-xyz'); + }, + ); + + feedsClientTest( + 'should connect a guest user on a retry after a failed createGuest call', + user: const User.guest('guest-123'), + connect: (tester) async { + tester.mockApiFailure( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest(user: UserRequest(id: 'guest-123')), + ), + error: Exception('Failed to create guest'), + ); + + await expectLater(tester.client.connect(), throwsA(isA())); + + addTearDown(tester.client.dispose); + }, + body: (tester) async { + tester.mockApi( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest(user: UserRequest(id: 'guest-123')), + ), + result: CreateGuestResponse( + accessToken: generateTestUserToken('guest-123-xyz').rawValue, + duration: '10ms', + user: createDefaultUserResponse(id: 'guest-123-xyz', role: 'guest'), + ), + ); + tester.mockSuccessfulAuth('guest-123-xyz'); + + // The failed attempt left nothing behind, so the exchange runs again + await tester.client.connect(); + + expect(tester.client.connectionState.value, isA()); + expect(tester.client.user.id, 'guest-123-xyz'); }, ); }); diff --git a/packages/stream_feeds/test/state/activity_comment_list_test.dart b/packages/stream_feeds/test/state/activity_comment_list_test.dart index 6f161e46..261a5f40 100644 --- a/packages/stream_feeds/test/state/activity_comment_list_test.dart +++ b/packages/stream_feeds/test/state/activity_comment_list_test.dart @@ -199,7 +199,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-level comment', + text: 'Top-priority comment', userId: userId, ), ], @@ -345,7 +345,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-level comment', + text: 'Top-priority comment', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -388,7 +388,7 @@ void main() { final updatedTopLevelComment = tester.activityCommentListState.comments.first; expect(updatedTopLevelComment.replies, isEmpty); expect(updatedTopLevelComment.replyCount, 0); - // Top-level comment should still exist + // Top-priority comment should still exist expect(tester.activityCommentListState.comments, hasLength(1)); expect(tester.activityCommentListState.comments.first.id, commentId); }, @@ -404,7 +404,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-level comment', + text: 'Top-priority comment', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -458,7 +458,7 @@ void main() { final updatedSecondLevelComment = updatedTopLevelComment.replies!.first; expect(updatedSecondLevelComment.replies, isEmpty); expect(updatedSecondLevelComment.replyCount, 0); - // Second-level comment should still exist + // Second-priority comment should still exist expect(updatedTopLevelComment.replies, hasLength(1)); expect(updatedTopLevelComment.replies!.first.id, 'nested-reply-1'); }, @@ -791,7 +791,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-level comment', + text: 'Top-priority comment', userId: userId, replies: [ createDefaultThreadedCommentResponse( diff --git a/packages/stream_feeds/test/state/comment_reply_list_test.dart b/packages/stream_feeds/test/state/comment_reply_list_test.dart index 54dd49d2..0eccea7d 100644 --- a/packages/stream_feeds/test/state/comment_reply_list_test.dart +++ b/packages/stream_feeds/test/state/comment_reply_list_test.dart @@ -341,7 +341,7 @@ void main() { ); commentReplyListTest( - 'should skip top-level comments (only handles replies)', + 'should skip top-priority comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith(comments: const []), @@ -368,13 +368,13 @@ void main() { ), ); - // Verify state was not updated (only replies are added, not top-level comments) + // Verify state was not updated (only replies are added, not top-priority comments) expect(tester.commentReplyListState.replies, isEmpty); }, ); commentReplyListTest( - 'should skip top-level comment updates (only handles replies)', + 'should skip top-priority comment updates (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -414,7 +414,7 @@ void main() { ), ); - // Verify state was not updated (only replies are updated, not top-level comments) + // Verify state was not updated (only replies are updated, not top-priority comments) expect(tester.commentReplyListState.replies, hasLength(1)); expect( tester.commentReplyListState.replies.first.text, @@ -424,7 +424,7 @@ void main() { ); commentReplyListTest( - 'should skip top-level comment deletions (only handles replies)', + 'should skip top-priority comment deletions (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -459,7 +459,7 @@ void main() { ), ); - // Verify state was not updated (only replies are deleted, not top-level comments) + // Verify state was not updated (only replies are deleted, not top-priority comments) expect(tester.commentReplyListState.replies, hasLength(1)); }, ); @@ -474,14 +474,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, ), ], ), ), body: (tester) async { - // Initial state - has top-level reply + // Initial state - has top-priority reply expect(tester.commentReplyListState.replies, hasLength(1)); final initialReply = tester.commentReplyListState.replies.first; expect(initialReply.id, replyId); @@ -528,7 +528,7 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -587,7 +587,7 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -630,7 +630,7 @@ void main() { final updatedTopLevelReply = tester.commentReplyListState.replies.first; expect(updatedTopLevelReply.replies, isEmpty); expect(updatedTopLevelReply.replyCount, 0); - // Top-level reply should still exist + // Top-priority reply should still exist expect(tester.commentReplyListState.replies, hasLength(1)); expect(tester.commentReplyListState.replies.first.id, replyId); }, @@ -646,14 +646,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, ), ], ), ), body: (tester) async { - // Initial state - has top-level reply + // Initial state - has top-priority reply expect(tester.commentReplyListState.replies, hasLength(1)); final topLevelReply = tester.commentReplyListState.replies.first; expect(topLevelReply.replies, isNull); @@ -693,14 +693,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-level reply', + text: 'Second-priority reply', userId: userId, ), ], @@ -766,14 +766,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-level reply', + text: 'Second-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -839,14 +839,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-level reply', + text: 'Second-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -893,7 +893,7 @@ void main() { final updatedSecondLevelReply = updatedTopLevelReply.replies!.first; expect(updatedSecondLevelReply.replies, isEmpty); expect(updatedSecondLevelReply.replyCount, 0); - // Second-level reply should still exist + // Second-priority reply should still exist expect(updatedTopLevelReply.replies, hasLength(1)); expect(updatedTopLevelReply.replies!.first.id, 'nested-reply-1'); }, @@ -1088,7 +1088,7 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -1153,14 +1153,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-level reply', + text: 'Second-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -1232,14 +1232,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-level reply', + text: 'Top-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-level reply', + text: 'Second-priority reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -1301,7 +1301,7 @@ void main() { ); commentReplyListTest( - 'should skip reaction additions for top-level comments (only handles replies)', + 'should skip reaction additions for top-priority comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -1343,14 +1343,14 @@ void main() { ), ); - // Verify state was not updated (only replies get reactions, not top-level comments) + // Verify state was not updated (only replies get reactions, not top-priority comments) final updatedReply = tester.commentReplyListState.replies.first; expect(updatedReply.ownReactions, isEmpty); }, ); commentReplyListTest( - 'should skip reaction updates for top-level comments (only handles replies)', + 'should skip reaction updates for top-priority comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -1400,7 +1400,7 @@ void main() { ), ); - // Verify state was not updated (only replies get reactions updated, not top-level comments) + // Verify state was not updated (only replies get reactions updated, not top-priority comments) final updatedReply = tester.commentReplyListState.replies.first; expect(updatedReply.ownReactions, hasLength(1)); expect(updatedReply.ownReactions.first.type, reactionType); @@ -1408,7 +1408,7 @@ void main() { ); commentReplyListTest( - 'should skip reaction deletions for top-level comments (only handles replies)', + 'should skip reaction deletions for top-priority comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -1456,7 +1456,7 @@ void main() { ), ); - // Verify state was not updated (only replies get reactions deleted, not top-level comments) + // Verify state was not updated (only replies get reactions deleted, not top-priority comments) final updatedReply = tester.commentReplyListState.replies.first; expect(updatedReply.ownReactions, hasLength(1)); }, diff --git a/packages/stream_feeds_test/lib/src/testers/base_tester.dart b/packages/stream_feeds_test/lib/src/testers/base_tester.dart index 9bc73d10..47d93822 100644 --- a/packages/stream_feeds_test/lib/src/testers/base_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/base_tester.dart @@ -50,7 +50,7 @@ abstract base class BaseTester with ApiMockerMixin, CdnMockerMixin { /// The underlying StreamFeedsClient from which the subject was built. /// - /// Use this to access client-level properties and methods. + /// Use this to access client-priority properties and methods. /// /// Example: /// ```dart @@ -114,6 +114,21 @@ abstract base class BaseTester with ApiMockerMixin, CdnMockerMixin { return _wsTester.mockFailedAuth(errorCode: errorCode); } + /// Configures the WebSocket so that sending fails. + /// + /// The socket opens, but nothing put on it leaves — a send that throws rather than a + /// server that refuses, so nothing answers the authentication frame. + /// + /// Example: + /// ```dart + /// tester.mockFailedSend(); + /// + /// await expectLater(client.connect(), throwsA(isA())); + /// ``` + void mockFailedSend({Object? error}) { + return _wsTester.mockFailedSend(error: error); + } + /// Emits a WebSocket event and pumps the event loop. /// /// This method emits the given [event] through the WebSocket stream and @@ -202,8 +217,9 @@ Future createTester>({ required MockWebSocketChannel webSocketChannel, required T Function(WebSocketTester ws) create, }) async { - // Create WebSocket stream controller - final wsStreamController = StreamController(); + // Create WebSocket stream controller. Broadcast so the engine can listen + // again after a disconnect, the way a real socket can be reopened. + final wsStreamController = StreamController.broadcast(); test.addTearDown(wsStreamController.close); // Close controller after test // Create WebSocket tester @@ -241,6 +257,7 @@ Future createTester>({ void testWithTester>( String description, { User user = const User(id: 'luke_skywalker'), + TokenProvider? tokenProvider, required S Function(StreamFeedsClient client) build, required TesterFactory createTesterFn, FutureOr Function(T tester)? connect, @@ -266,11 +283,8 @@ void testWithTester>( final client = StreamFeedsClient( apiKey: 'apiKey', user: user, - tokenProvider: TokenProvider.static( - generateTestUserToken(user.id), - ), + tokenProvider: tokenProvider ?? TokenProvider.static(generateTestUserToken(user.id)), feedsRestApi: feedsApi, - guestRestApi: feedsApi, wsProvider: (options) => webSocketChannel, config: FeedsConfig( cdnClient: FeedsCdnClient(cdnApi), @@ -304,7 +318,7 @@ Future _defaultConnect(BaseTester tester) async { // Connect client await tester.client.connect(); - test.addTearDown(tester.client.disconnect); // Disconnect client after test + test.addTearDown(tester.client.dispose); // Dispose client after test // Verify client is connected test.expect(tester.client.connectionState.value, test.isA()); diff --git a/packages/stream_feeds_test/lib/src/testers/feeds_client_tester.dart b/packages/stream_feeds_test/lib/src/testers/feeds_client_tester.dart index e7fd4710..2fb87647 100644 --- a/packages/stream_feeds_test/lib/src/testers/feeds_client_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/feeds_client_tester.dart @@ -42,6 +42,7 @@ import 'base_tester.dart'; void feedsClientTest( String description, { User user = const User(id: 'luke_skywalker'), + TokenProvider? tokenProvider, FutureOr Function(FeedsClientTester tester)? connect, FutureOr Function(FeedsClientTester tester)? setUp, required FutureOr Function(FeedsClientTester tester) body, @@ -54,6 +55,7 @@ void feedsClientTest( return testWithTester( description, user: user, + tokenProvider: tokenProvider, build: (client) => client, createTesterFn: _createFeedsClientTester, connect: connect, diff --git a/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart b/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart index 24eab6b0..cbead2b9 100644 --- a/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart @@ -80,6 +80,28 @@ final class WebSocketTester { ); } + /// Configures the WebSocket so that sending fails. + /// + /// The socket opens, but the authentication frame never leaves it — a send that + /// throws rather than a server that refuses. Nothing answers, so a connection that + /// ignored the failure would sit in [Authenticating] until it timed out. + /// + /// Call this before connecting the client. + /// + /// Example: + /// ```dart + /// wsTester.mockFailedSend(); + /// await expectLater(client.connect(), throwsA(isA())); + /// ``` + void mockFailedSend({Object? error}) { + _resetFunction?.call(); // Reset previous mocks if any + _resetFunction = _whenListenWebSocket( + _channel, + _streamController, + sendError: error ?? Exception('the socket went away mid-handshake'), + ); + } + /// Configures WebSocket mocks to simulate authentication failure. /// /// Sets up the WebSocket channel to always respond with an error event @@ -88,30 +110,31 @@ final class WebSocketTester { /// simulate the server closing the connection. /// /// The [errorCode] parameter allows customizing the error code returned. - /// Default is 40 (expiredToken), which prevents automatic reconnection. - /// All errors use HTTP status code 401. + /// Default is 40 (expiredToken). All errors use HTTP status code 401. /// /// **Backend Error Codes (401 Status):** /// - /// **No Reconnection (Token expired errors 40-42):** + /// **Reconnected**, because the next attempt loads another token — unless the + /// provider is static and has none to give, which closes the connection: /// - `40`: expiredToken - Token has expired + /// + /// **Reported, never retried**, because no other token repairs it: /// - `41`: tokenNotValidYet - Token not yet valid /// - `42`: tokenUsedBeforeIAT - Token used before issued - /// - /// **Triggers Reconnection:** - /// - `2`: accessKeyError - Invalid API key + /// - `43`: invalidTokenSignature - Signed with the wrong secret + /// - `2`: accessKeyError - Wrong API key /// - `5`: authFailed - Authentication failed - /// - `43`: invalidTokenSignature - Invalid signature /// /// Call this before connecting the client when testing error scenarios. /// /// Example: /// ```dart - /// // Test with token expired error (no reconnection) + /// // An expired token: reconnected, with a token loaded afresh — unless the + /// // provider is static, which has no other token to offer. /// wsTester.mockFailedAuth(); /// - /// // Test with auth failed error (triggers reconnection) - /// wsTester.mockFailedAuth(errorCode: 5); + /// // A signature no other token repairs: reported, never retried. + /// wsTester.mockFailedAuth(errorCode: 43); /// /// await expectLater( /// client.connect(), @@ -170,9 +193,10 @@ WebSocketResetFunction _whenListenWebSocket( MockWebSocketChannel webSocketChannel, StreamController wsStreamController, { void Function(UserToken token)? onConnectionAttempt, + Object? sendError, }) { final webSocketSink = MockWebSocketSink(); - final webSocketStream = wsStreamController.stream.asBroadcastStream(); + final webSocketStream = wsStreamController.stream; // Mock sink close operation when( @@ -195,6 +219,11 @@ WebSocketResetFunction _whenListenWebSocket( ).thenAnswer((_) => webSocketSink); // Handle authentication: when a token is sent, invoke the callback + if (sendError case final error?) { + when(() => webSocketSink.add(any())).thenThrow(error); + return () => reset(webSocketSink); + } + when( () => webSocketSink.add(any()), ).thenAnswer((invocation) { @@ -256,8 +285,9 @@ Map _createConnectedEvent(String userId) { // // The [errorCode] parameter determines the specific error code returned, // which controls the automatic reconnection behavior: -// - Error codes 40-42 prevent automatic reconnection (token expired errors) -// - Other error codes (2, 5, 43) trigger automatic reconnection +// - 40 (expired) is reconnected, since the next attempt loads another token +// - 41, 42, 43 and 2 are not: no other token repairs them +// - any other client error is not either // // See [mockFailedAuth] for the complete list of backend error codes and // their reconnection behavior. From ae0df1ee7dfd9c9a26c2221db6da776b29e08519 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:36:48 +0200 Subject: [PATCH 13/31] docs(llc): describe the connection lifecycle and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the guest entries into the one that already announces the feature — a reader upgrading never saw the broken intermediate states — and drops the rationale from the rest, leaving what each change means for someone using the SDK. Co-Authored-By: Claude Opus 5 (1M context) --- docs/analysis_options.yaml | 8 ++ docs/code_snippets/02_02_authentication.dart | 46 +++++++--- docs/code_snippets/12_01_logging.dart | 93 ++++++++++++++++++++ packages/stream_feeds/CHANGELOG.md | 14 ++- 4 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 docs/code_snippets/12_01_logging.dart diff --git a/docs/analysis_options.yaml b/docs/analysis_options.yaml index 038e843c..088f1501 100644 --- a/docs/analysis_options.yaml +++ b/docs/analysis_options.yaml @@ -3,6 +3,14 @@ include: ../analysis_options.yaml analyzer: errors: unused_local_variable: ignore + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/docs/code_snippets/02_02_authentication.dart b/docs/code_snippets/02_02_authentication.dart index c77568c3..69ca519f 100644 --- a/docs/code_snippets/02_02_authentication.dart +++ b/docs/code_snippets/02_02_authentication.dart @@ -29,30 +29,56 @@ Future dynamicTokenProvider() async { Future fetchTokenFromYourServer(String userId) async => ''; Future guestUserLogin() async { - // Guest user: the SDK automatically calls POST /api/v2/guest to obtain - // a temporary JWT — no tokenProvider is needed. - // Guest users have full read/write access and a real WebSocket connection, - // but their session is temporary and not tied to a persistent account. + // Guest user: the SDK obtains a temporary token during connect, so no + // tokenProvider is needed. The session is temporary and is not tied to a + // persistent account. final client = StreamFeedsClient( apiKey: '', user: User.guest('guest-${DateTime.now().millisecondsSinceEpoch}'), ); - await client.connect(); // Guest JWT is fetched automatically on connect. + await client.connect(); + // The server assigns the guest its own id, so read it from `client.user` + // rather than reusing the id you asked for. final feed = client.feed(group: 'user', id: client.user.id); await feed.getOrCreate(); } Future anonymousUserLogin() async { - // Anonymous user: read-only access with no JWT or WebSocket connection. - // Use this for public feeds that don't require authentication. - // Note: calling connect() throws for anonymous users. + // Anonymous user: no token of its own and no WebSocket connection. Use it to + // read public feeds. Calling connect() is not required, and opens no + // connection for an anonymous user. final client = StreamFeedsClient( apiKey: '', user: const User.anonymous(), ); - // Read public feed data without connecting. - final feed = client.feed(group: 'user', id: 'alice'); + // Watching requires a connection, so ask for a feed that is not watched. + final feed = client.feedFromQuery( + const FeedQuery( + fid: FeedId(group: 'user', id: 'alice'), + watch: false, + ), + ); + await feed.getOrCreate(); +} + +Future requestOnlyLogin() async { + // Authenticate without opening a WebSocket, for a client that only makes + // requests. No events are emitted, and a watched query is rejected because + // watching requires a connection. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + ); + await client.connect(connectWebSocket: false); + + final feed = client.feedFromQuery( + const FeedQuery( + fid: FeedId(group: 'user', id: 'alice'), + watch: false, + ), + ); await feed.getOrCreate(); } diff --git a/docs/code_snippets/12_01_logging.dart b/docs/code_snippets/12_01_logging.dart new file mode 100644 index 00000000..d8c4ad97 --- /dev/null +++ b/docs/code_snippets/12_01_logging.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +Future seeWhatTheClientIsDoing() async { + // Nothing is logged until you ask. A priority on its own writes to the console. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + config: const FeedsConfig( + logConfig: StreamLogConfig(priority: StreamLogPriority.debug), + ), + ); + await client.connect(); +} + +Future sendRecordsSomewhereElse() async { + // A handler of your own replaces the console. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + config: const FeedsConfig( + logConfig: StreamLogConfig( + priority: StreamLogPriority.debug, + handler: StreamLogHandler.from(reportToYourCrashReporter), + ), + ), + ); + await client.connect(); +} + +Future keepTheConsoleAsWell() async { + // Or compose with the one the client would have used. Naming it only under `kDebugMode` keeps a + // console for whoever is developing without leaving one in the build your users run, while the + // crash reporter goes on receiving records everywhere. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + config: const FeedsConfig( + logConfig: StreamLogConfig( + priority: StreamLogPriority.debug, + handler: StreamLogHandler.composite([ + if (kDebugMode) StreamLogConfig.defaultHandler, + StreamLogHandler.from(reportToYourCrashReporter), + ]), + ), + ), + ); + await client.connect(); +} + +Future onlyWhileDeveloping() async { + // `kDebugMode` is what leaves a console out of the build your users run. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + config: const FeedsConfig( + logConfig: StreamLogConfig( + priority: StreamLogPriority.debug, + handler: kDebugMode ? StreamLogConfig.defaultHandler : StreamLogHandler.silent, + ), + ), + ); + await client.connect(); +} + +Future turnUpOneSubsystem() async { + // Records are tagged `SF:Ws` for the connection, `SF:Http` for the requests it makes and + // `SF:HttpAuth` for the tokens it signs them with. A filter picks out one of them, or tells this + // SDK's records apart from another Stream SDK sharing the same handler. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + config: const FeedsConfig( + logConfig: StreamLogConfig( + filter: StreamLogFilter.prefix( + {'SF:Ws': StreamLogPriority.verbose}, + otherwise: StreamLogPriority.warning, + ), + ), + ), + ); + await client.connect(); +} + +// Placeholder for wherever your app sends its diagnostics. +void reportToYourCrashReporter(StreamLogRecord record) { + debugPrint('${record.time} ${record.priority.label}/${record.tag}: ${record.message}'); +} diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 07e53263..a0906119 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,7 +1,19 @@ ## Upcoming ### Improvements -- Guest users (`User.guest(id)`) now obtain a real JWT by calling `POST /api/v2/guest` during `connect()`, giving them a full authenticated session with WebSocket support. Previously guest users fell back to the anonymous token which prevented WS connectivity. If the backend assigns a different id to the guest user, `client.user` is updated to match it. +- Guest users (`User.guest(id)`) now obtain a real JWT by calling `POST /api/v2/guest` during `connect()`, giving them a full authenticated session with WebSocket support. Previously guest users fell back to the anonymous token which prevented WS connectivity. The server assigns the id, so `client.user` is updated to match it, and the identity is established once — reconnecting resumes it rather than creating another guest. +- `disconnect()` now only closes the connection, leaving the client reusable: subscriptions to `events`, `stateUpdateEvents` and `connectionState` keep working across any number of `connect()`/`disconnect()` cycles. It previously closed them too, so a reconnected client emitted no further state updates and never reconnected automatically again. +- Added `dispose()`, which releases what `disconnect()` used to, along with the WebSocket client and the capabilities batcher. It is terminal: `connect()` throws a `StateError` afterwards, and calling it twice does nothing. An injected `feedsRestApi` or `wsProvider` is left alone, since the client does not own it. +- `connect()` takes a `connectWebSocket` flag. Pass `false` to authenticate without opening a WebSocket, for a client that only makes requests: no events are emitted, and a watched query is rejected, since watching requires a connection. An anonymous user always connects this way. +- A token the server refuses as expired now recovers on its own, reconnecting with one the `TokenProvider` issued afterwards, so a token expiring mid-session no longer leaves the client offline until the app calls `connect()` again. A static token has none to replace it, so the connection is left closed with an authentication failure rather than presenting the refused token again. +- `connect()` now throws a `ClientException` when a connection is already established or in progress, rather than returning without connecting, and the one it throws on failure now carries the underlying cause instead of only a close reason. + +- Added `FeedsConfig.logConfig`, which says how much the client reports and where those records go. Left out, the client installs nothing, so it stays silent and leaves the logger to whichever Stream SDK beside it configured one. Records carry an `SF:` tag, so a handler shared with another SDK can still tell them apart. + +### Bug fixes +- Fixed the HTTP logs carrying the `Authorization` header the request was signed with, which put the user's token in the console of every app that had logging on. +- Fixed `connect()` failing when called straight after `disconnect()`. `disconnect()` returned before the socket had closed, so the next `connect()` observed the pending closure and reported it as a failed connection. +- Fixed a failure to send the WebSocket authentication frame being ignored. The connection sat in `Authenticating` until it timed out; it now closes immediately with the real cause. ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. From abe4ae30d5dbebd4d2e9ea11173098b7c0b4d9f3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:36:48 +0200 Subject: [PATCH 14/31] feat(sample): sign in as a guest, and report through the logger A guest entry in the user picker exercises the token exchange end to end. Its token is obtained during `connect`, so no `tokenProvider` is passed, and the id the server assigns is what the app shows afterwards. Signing out disposes the client rather than disconnecting it, since that one is finished. Co-Authored-By: Claude Opus 5 (1M context) --- sample_app/analysis_options.yaml | 7 ++++ sample_app/lib/app/content/app_content.dart | 11 ++++-- .../lib/app/content/auth_controller.dart | 38 ++++++++++++++++--- .../lib/core/models/user_credentials.dart | 26 +++++++++++-- .../lib/navigation/guards/auth_guard.dart | 4 ++ .../notification_background_handler.dart | 12 ++++-- .../notification/notification_service.dart | 9 +++-- .../choose_user/choose_user_screen.dart | 17 +++++++-- sample_app/lib/services/app_preferences.dart | 5 ++- sample_app/lib/widgets/app_splash.dart | 20 ++++++++++ 10 files changed, 124 insertions(+), 25 deletions(-) diff --git a/sample_app/analysis_options.yaml b/sample_app/analysis_options.yaml index f031fcbe..7ccf8e2a 100644 --- a/sample_app/analysis_options.yaml +++ b/sample_app/analysis_options.yaml @@ -6,6 +6,13 @@ analyzer: # exclude all the generated files - lib/**/*.*.dart - lib/firebase_options.dart + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/sample_app/lib/app/content/app_content.dart b/sample_app/lib/app/content/app_content.dart index 19ed9041..750bbd6d 100644 --- a/sample_app/lib/app/content/app_content.dart +++ b/sample_app/lib/app/content/app_content.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart' show StreamLogger; import '../../core/di/di_initializer.dart'; import '../../core/models/user_credentials.dart'; @@ -10,6 +11,8 @@ import '../../notification/notification_service.dart'; import '../../theme/theme.dart'; import 'auth_controller.dart'; +const _logger = StreamLogger('App:Push'); + class StreamFeedsSampleAppContent extends StatefulWidget { const StreamFeedsSampleAppContent({super.key, this.credentials}); @@ -29,10 +32,10 @@ class _StreamFeedsSampleAppContentState extends State '📱 Notification tapped: ${notification.type}'); + _logger.d(() => '📱 Device state: ${info.deviceState}'); + _logger.d(() => '📱 Title: ${notification.title}'); + _logger.d(() => '📱 Body: ${notification.body}'); // Navigate to the relevant screen based on notification type. } diff --git a/sample_app/lib/app/content/auth_controller.dart b/sample_app/lib/app/content/auth_controller.dart index 2b2890b1..097309b5 100644 --- a/sample_app/lib/app/content/auth_controller.dart +++ b/sample_app/lib/app/content/auth_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:injectable/injectable.dart'; import 'package:stream_feeds/stream_feeds.dart'; @@ -8,6 +9,8 @@ import '../../push/push_provider.dart'; import '../../push/push_token_manager.dart'; import '../../services/app_preferences.dart'; +const _logger = StreamLogger('App:Auth'); + @lazySingleton class AuthController extends ValueNotifier { AuthController( @@ -28,19 +31,27 @@ class AuthController extends ValueNotifier { Future connect(UserCredentials credentials) async { value = const Authenticating(); + _logger.d(() => 'connecting ${credentials.user.id}'); - final token = UserToken(credentials.token); + final token = credentials.token; final client = StreamFeedsClient( user: credentials.user, apiKey: DemoAppConfig.current.apiKey, - tokenProvider: TokenProvider.static(token), + // A guest must be given none: the client obtains its own during `connect()`. + tokenProvider: token?.let((it) => TokenProvider.static(UserToken(it))), networkStateProvider: _networkStateProvider, lifecycleStateProvider: _lifecycleStateProvider, + config: const FeedsConfig( + logConfig: StreamLogConfig( + priority: kDebugMode ? .debug : .info, + ), + ), ); final result = await runSafely(client.connect); result.onSuccess((_) { + // Stored as it was asked for, so a guest asks again on the next launch. _appPreferences.storeUserCredentials(credentials); // Initialize the push manager if not already initialized @@ -55,8 +66,22 @@ class AuthController extends ValueNotifier { }); value = result.fold( - onSuccess: (_) => Authenticated(credentials.user, client), - onFailure: (_, _) => const Unauthenticated(), + // The server assigns a guest its id, so the client's user is the one that + // connected, not the one that was asked for. + onSuccess: (_) { + _logger.d(() => 'connected as ${client.user.id}'); + return Authenticated(client.user, client); + }, + // Reported rather than swallowed: dropping to the user picker with no explanation is the + // one moment this is worth interrupting for. + onFailure: (error, stackTrace) { + _logger.w( + () => 'could not connect ${credentials.user.id}', + error: error, + stackTrace: stackTrace, + ); + return const Unauthenticated(); + }, ); } @@ -70,7 +95,10 @@ class AuthController extends ValueNotifier { _pushTokenManager?.unregisterDevice().ignore(); _pushTokenManager = null; - client.disconnect().ignore(); + _logger.d(() => 'disconnecting ${client.user.id}'); + + // Disposed rather than disconnected: this client is not used again. + client.dispose().ignore(); await _appPreferences.clearUserCredentials(); value = const Unauthenticated(); diff --git a/sample_app/lib/core/models/user_credentials.dart b/sample_app/lib/core/models/user_credentials.dart index 34627862..e4d47232 100644 --- a/sample_app/lib/core/models/user_credentials.dart +++ b/sample_app/lib/core/models/user_credentials.dart @@ -5,11 +5,14 @@ import '../../config/demo_app_config.dart'; class UserCredentials { const UserCredentials({ required this.user, - required this.token, + this.token, }); final User user; - final String token; + + /// The JWT to authenticate [user] with, or `null` for a guest or anonymous + /// user, whose credentials the client obtains for itself. + final String? token; // Helper method to get feed ID String get fid => 'user:${user.id}'; @@ -103,7 +106,23 @@ class UserCredentials { // endregion - // Built-in list sorted by name + // region Session modes + + /// A guest, which the server issues a temporary identity and JWT for during + /// `connect()`. The id below is only what is asked for: the server assigns its + /// own, so `client.user.id` is the one to read afterwards. + static const guest = UserCredentials( + user: User.guest( + 'guest', + name: 'Guest User', + image: 'https://getstream.io/random_png/?id=guest&name=Guest+User', + ), + ); + + // endregion + + // Built-in list sorted by name, with the token-less identity last so the test + // accounts stay the obvious choice. static List get builtIn { final users = [ sahil, @@ -115,6 +134,7 @@ class UserCredentials { marcelo, kanat, toomas, + guest, ]; return users; diff --git a/sample_app/lib/navigation/guards/auth_guard.dart b/sample_app/lib/navigation/guards/auth_guard.dart index 92b838cd..5f3d0cac 100644 --- a/sample_app/lib/navigation/guards/auth_guard.dart +++ b/sample_app/lib/navigation/guards/auth_guard.dart @@ -1,9 +1,12 @@ import 'package:auto_route/auto_route.dart'; import 'package:injectable/injectable.dart'; +import 'package:stream_feeds/stream_feeds.dart' show StreamLogger; import '../../app/content/auth_controller.dart'; import '../app_router.dart'; +const _logger = StreamLogger('App:Nav'); + @injectable class AuthGuard extends AutoRouteGuard { const AuthGuard(this._authController); @@ -12,6 +15,7 @@ class AuthGuard extends AutoRouteGuard { @override void onNavigation(NavigationResolver resolver, StackRouter router) { + _logger.d(() => 'auth guard: ${resolver.routeName} while ${_authController.value.runtimeType}'); final isAuthenticated = _authController.value is Authenticated; // If the user is authenticated, allow navigation to the requested route. if (isAuthenticated) return resolver.next(); diff --git a/sample_app/lib/notification/notification_background_handler.dart b/sample_app/lib/notification/notification_background_handler.dart index 2d1a36e7..87923600 100644 --- a/sample_app/lib/notification/notification_background_handler.dart +++ b/sample_app/lib/notification/notification_background_handler.dart @@ -1,11 +1,12 @@ import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart' show debugPrint; import 'package:stream_feeds/stream_feeds.dart'; import '../core/di/di_initializer.dart'; import 'notification.dart'; import 'notification_service.dart'; +const _logger = StreamLogger('App:Push'); + /// Background message handler for Firebase Cloud Messaging. /// /// This function is called when the app receives a push notification @@ -26,9 +27,12 @@ Future onBackgroundMessageHandler(RemoteMessage message) async { // Only handle notifications sent from Stream Feeds if (notification.sender != 'stream.feeds') return; - debugPrint('📨 Background message received: ${notification.type}'); - debugPrint('📨 Title: ${notification.title}'); - debugPrint('📨 Body: ${notification.body}'); + // A background message runs in an isolate of its own, which shares no statics with the app, so + // the logger is set up again here rather than by whatever configured it there. + StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug)); + _logger.d(() => '📨 Background message received: ${notification.type}'); + _logger.d(() => '📨 Title: ${notification.title}'); + _logger.d(() => '📨 Body: ${notification.body}'); await initDI(); // Ensure dependencies are initialized diff --git a/sample_app/lib/notification/notification_service.dart b/sample_app/lib/notification/notification_service.dart index 24652a79..d03ac806 100644 --- a/sample_app/lib/notification/notification_service.dart +++ b/sample_app/lib/notification/notification_service.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:injectable/injectable.dart'; import 'package:stream_feeds/stream_feeds.dart'; @@ -14,6 +13,8 @@ import '../core/di/app_module.dart'; import 'notification.dart'; import 'notification_background_handler.dart'; +const _logger = StreamLogger('App:Push'); + const notificationChannelId = 'stream_feeds_channel'; const notificationChannelName = 'Stream Feeds Notifications'; const notificationChannelDescription = 'Notifications for Stream Feeds'; @@ -192,9 +193,9 @@ class NotificationService extends Disposable { // Only handle notifications sent from Stream Feeds if (notification.sender != 'stream.feeds') return; - debugPrint('📱 Foreground message received: ${notification.type}'); - debugPrint('📱 Title: ${notification.title}'); - debugPrint('📱 Body: ${notification.body}'); + _logger.d(() => '📱 Foreground message received: ${notification.type}'); + _logger.d(() => '📱 Title: ${notification.title}'); + _logger.d(() => '📱 Body: ${notification.body}'); // Show local notification for foreground messages if needed } diff --git a/sample_app/lib/screens/choose_user/choose_user_screen.dart b/sample_app/lib/screens/choose_user/choose_user_screen.dart index 8628780e..3fb3cb3f 100644 --- a/sample_app/lib/screens/choose_user/choose_user_screen.dart +++ b/sample_app/lib/screens/choose_user/choose_user_screen.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:stream_feeds/stream_feeds.dart'; import '../../app/content/auth_controller.dart'; import '../../core/di/di_initializer.dart'; @@ -66,14 +67,16 @@ class UserSelectionList extends StatelessWidget { @override Widget build(BuildContext context) { + final credentials = UserCredentials.builtIn; + return ListView.separated( - itemCount: UserCredentials.builtIn.length, + itemCount: credentials.length, separatorBuilder: (context, index) => Divider( height: 1, color: context.appColors.borders, ), itemBuilder: (context, index) { - final credential = UserCredentials.builtIn[index]; + final credential = credentials[index]; return ListTile( key: Key(credential.user.id), @@ -85,7 +88,7 @@ class UserSelectionList extends StatelessWidget { style: context.appTextStyles.bodyBold, ), subtitle: Text( - 'Stream test account', + _subtitleFor(credential), style: context.appTextStyles.footnote.copyWith( color: context.appColors.textLowEmphasis, ), @@ -98,4 +101,12 @@ class UserSelectionList extends StatelessWidget { }, ); } + + String _subtitleFor(UserCredentials credential) { + return switch (credential.user.type) { + UserType.regular => 'Stream test account', + UserType.guest => 'Temporary account, issued on connect', + UserType.anonymous => 'Public feeds only, no connection', + }; + } } diff --git a/sample_app/lib/services/app_preferences.dart b/sample_app/lib/services/app_preferences.dart index 779c0b50..a3f75e6a 100644 --- a/sample_app/lib/services/app_preferences.dart +++ b/sample_app/lib/services/app_preferences.dart @@ -21,8 +21,9 @@ class AppPreferences { final userId = _prefs.getString(_loggedUserId); if (userId == null) return null; - final builtInUsers = UserCredentials.builtIn; - return builtInUsers.firstWhereOrNull((it) => it.user.id == userId); + // A guest is stored under the id it asks for, not the one the server assigned + // it, so relaunching asks again and comes back as a new guest. + return UserCredentials.builtIn.firstWhereOrNull((it) => it.user.id == userId); } Future storeUserCredentials(UserCredentials credentials) { diff --git a/sample_app/lib/widgets/app_splash.dart b/sample_app/lib/widgets/app_splash.dart index 11d7e06e..4b049d22 100644 --- a/sample_app/lib/widgets/app_splash.dart +++ b/sample_app/lib/widgets/app_splash.dart @@ -30,3 +30,23 @@ class AppSplash extends StatelessWidget { ); } } + +/// [AppSplash] with an app shell of its own. +/// +/// For the two moments the splash is shown above the app's own [MaterialApp] — before +/// initialisation finishes, and while an identity is being connected — where it would otherwise +/// find no theme and fall back. +class AppSplashScreen extends StatelessWidget { + /// Creates an [AppSplashScreen]. + const AppSplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeConfig.lightTheme, + darkTheme: ThemeConfig.darkTheme, + home: const AppSplash(), + ); + } +} From d1a781dcf31f048be93dbbff28323629c152e8ed Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:41:32 +0200 Subject: [PATCH 15/31] chore(sample): drop the unused splash shell `@RoutePage` sits on `AppSplash`, so `AppSplashRoute` resolves to that, and nothing ever constructed `AppSplashScreen`. The splash is only ever shown inside an app that already has a theme, which is what the shell existed to provide. Co-Authored-By: Claude Opus 5 (1M context) --- sample_app/lib/widgets/app_splash.dart | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/sample_app/lib/widgets/app_splash.dart b/sample_app/lib/widgets/app_splash.dart index 4b049d22..11d7e06e 100644 --- a/sample_app/lib/widgets/app_splash.dart +++ b/sample_app/lib/widgets/app_splash.dart @@ -30,23 +30,3 @@ class AppSplash extends StatelessWidget { ); } } - -/// [AppSplash] with an app shell of its own. -/// -/// For the two moments the splash is shown above the app's own [MaterialApp] — before -/// initialisation finishes, and while an identity is being connected — where it would otherwise -/// find no theme and fall back. -class AppSplashScreen extends StatelessWidget { - /// Creates an [AppSplashScreen]. - const AppSplashScreen({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeConfig.lightTheme, - darkTheme: ThemeConfig.darkTheme, - home: const AppSplash(), - ); - } -} From a33cdcd78c6e715b1dcf5bfc2378df25a41ed86b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:43:48 +0200 Subject: [PATCH 16/31] chore(deps): move the stream_core pin to the current branch tip Picks up the `DisconnectionSource.cause` doc and the expression-body getters that landed on the connection-lifecycle branch after the previous pin was taken. Co-Authored-By: Claude Opus 5 (1M context) --- melos.yaml | 2 +- packages/stream_feeds/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/melos.yaml b/melos.yaml index de66bc60..9a11eeba 100644 --- a/melos.yaml +++ b/melos.yaml @@ -50,7 +50,7 @@ command: stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 25588a1b1b1541c2ee7409f68eb8076f2ad8ad81 + ref: 8260248b8fa08d8eb0eee66afa369b3a1a1f5aef path: packages/stream_core video_player: ^2.10.0 uuid: ^4.5.1 diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 50d9ea8b..91db782c 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 25588a1b1b1541c2ee7409f68eb8076f2ad8ad81 + ref: 8260248b8fa08d8eb0eee66afa369b3a1a1f5aef path: packages/stream_core uuid: ^4.5.1 From 3d861f0d4faf49b5d8a2d3908bfc817212a49e9a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:51:38 +0200 Subject: [PATCH 17/31] chore: raise the minimum Dart SDK to ^3.12.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stream_core` requires it, so resolution fails on anything older — which is what the legacy-version job reports. The packages here claimed `^3.10.0` while depending on it, so the constraint was the thing that was wrong. Raising the language version turns on `prefer_initializing_formals`, applied by `dart fix` across the state classes and two providers in the sample app. Co-Authored-By: Claude Opus 5 (1M context) --- docs/pubspec.yaml | 2 +- packages/stream_feeds/example/pubspec.yaml | 2 +- packages/stream_feeds/lib/src/state/activity.dart | 4 ++-- .../lib/src/state/activity_comment_list.dart | 4 ++-- packages/stream_feeds/lib/src/state/activity_list.dart | 4 ++-- .../lib/src/state/activity_reaction_list.dart | 4 ++-- .../lib/src/state/bookmark_folder_list.dart | 4 ++-- packages/stream_feeds/lib/src/state/bookmark_list.dart | 4 ++-- packages/stream_feeds/lib/src/state/comment_list.dart | 4 ++-- .../lib/src/state/comment_reaction_list.dart | 4 ++-- .../stream_feeds/lib/src/state/comment_reply_list.dart | 4 ++-- packages/stream_feeds/lib/src/state/feed.dart | 4 ++-- packages/stream_feeds/lib/src/state/feed_list.dart | 4 ++-- packages/stream_feeds/lib/src/state/follow_list.dart | 4 ++-- packages/stream_feeds/lib/src/state/member_list.dart | 4 ++-- packages/stream_feeds/lib/src/state/poll_list.dart | 4 ++-- .../stream_feeds/lib/src/state/poll_vote_list.dart | 4 ++-- packages/stream_feeds/pubspec.yaml | 2 +- packages/stream_feeds_test/pubspec.yaml | 2 +- pubspec.yaml | 2 +- sample_app/lib/push/push_provider.dart | 10 ++++------ .../reconnect_providers/network_state_provider.dart | 4 ++-- sample_app/pubspec.yaml | 2 +- 23 files changed, 42 insertions(+), 44 deletions(-) diff --git a/docs/pubspec.yaml b/docs/pubspec.yaml index d322a3b2..91e22e9e 100644 --- a/docs/pubspec.yaml +++ b/docs/pubspec.yaml @@ -1,7 +1,7 @@ name: docs environment: - sdk: ^3.10.0 + sdk: ^3.12.0 dependencies: flutter: diff --git a/packages/stream_feeds/example/pubspec.yaml b/packages/stream_feeds/example/pubspec.yaml index be5c2dee..4bddab63 100644 --- a/packages/stream_feeds/example/pubspec.yaml +++ b/packages/stream_feeds/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ^3.10.0 + sdk: ^3.12.0 dependencies: collection: ^1.18.0 diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index 9cbf49ce..3ea211d5 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -44,8 +44,8 @@ class Activity with Disposable { required this.pollsRepository, required this.capabilitiesRepository, ActivityData? initialActivityData, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _commentsList = ActivityCommentList( query: ActivityCommentsQuery( objectId: activityId, diff --git a/packages/stream_feeds/lib/src/state/activity_comment_list.dart b/packages/stream_feeds/lib/src/state/activity_comment_list.dart index 19c977bd..ff197a43 100644 --- a/packages/stream_feeds/lib/src/state/activity_comment_list.dart +++ b/packages/stream_feeds/lib/src/state/activity_comment_list.dart @@ -25,8 +25,8 @@ class ActivityCommentList extends Disposable { required this.query, required this.commentsRepository, required this.currentUserId, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = ActivityCommentListStateNotifier( currentUserId: currentUserId, initialState: const ActivityCommentListState(), diff --git a/packages/stream_feeds/lib/src/state/activity_list.dart b/packages/stream_feeds/lib/src/state/activity_list.dart index ede182bf..f0a94257 100644 --- a/packages/stream_feeds/lib/src/state/activity_list.dart +++ b/packages/stream_feeds/lib/src/state/activity_list.dart @@ -29,8 +29,8 @@ class ActivityList with Disposable { required this.currentUserId, required this.activitiesRepository, required this.capabilitiesRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = ActivityListStateNotifier( currentUserId: currentUserId, initialState: const ActivityListState(), diff --git a/packages/stream_feeds/lib/src/state/activity_reaction_list.dart b/packages/stream_feeds/lib/src/state/activity_reaction_list.dart index f965bee1..7080dd4d 100644 --- a/packages/stream_feeds/lib/src/state/activity_reaction_list.dart +++ b/packages/stream_feeds/lib/src/state/activity_reaction_list.dart @@ -26,8 +26,8 @@ class ActivityReactionList extends Disposable { ActivityReactionList({ required this.query, required this.activitiesRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = ActivityReactionListStateNotifier( initialState: ActivityReactionListState(query: query), ); diff --git a/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart b/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart index fa13d57a..ad3b69be 100644 --- a/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart +++ b/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart @@ -26,8 +26,8 @@ class BookmarkFolderList extends Disposable { BookmarkFolderList({ required this.query, required this.bookmarksRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = BookmarkFolderListStateNotifier( initialState: BookmarkFolderListState(query: query), ); diff --git a/packages/stream_feeds/lib/src/state/bookmark_list.dart b/packages/stream_feeds/lib/src/state/bookmark_list.dart index 48959269..b919c97b 100644 --- a/packages/stream_feeds/lib/src/state/bookmark_list.dart +++ b/packages/stream_feeds/lib/src/state/bookmark_list.dart @@ -26,8 +26,8 @@ class BookmarkList with Disposable { BookmarkList({ required this.query, required this.bookmarksRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = BookmarkListStateNotifier( initialState: const BookmarkListState(), ); diff --git a/packages/stream_feeds/lib/src/state/comment_list.dart b/packages/stream_feeds/lib/src/state/comment_list.dart index edc7d439..a4efa1a5 100644 --- a/packages/stream_feeds/lib/src/state/comment_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_list.dart @@ -26,8 +26,8 @@ class CommentList extends Disposable { required this.query, required this.commentsRepository, required this.currentUserId, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = CommentListStateNotifier( currentUserId: currentUserId, initialState: const CommentListState(), diff --git a/packages/stream_feeds/lib/src/state/comment_reaction_list.dart b/packages/stream_feeds/lib/src/state/comment_reaction_list.dart index 272f0d40..8629d1a0 100644 --- a/packages/stream_feeds/lib/src/state/comment_reaction_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_reaction_list.dart @@ -26,8 +26,8 @@ class CommentReactionList with Disposable { CommentReactionList({ required this.query, required this.commentsRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = CommentReactionListStateNotifier( initialState: const CommentReactionListState(), ); diff --git a/packages/stream_feeds/lib/src/state/comment_reply_list.dart b/packages/stream_feeds/lib/src/state/comment_reply_list.dart index 6728ae20..f0e4e7c3 100644 --- a/packages/stream_feeds/lib/src/state/comment_reply_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_reply_list.dart @@ -26,8 +26,8 @@ class CommentReplyList with Disposable { required this.query, required this.currentUserId, required this.commentsRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = CommentReplyListStateNotifier( currentUserId: currentUserId, parentCommentId: query.commentId, diff --git a/packages/stream_feeds/lib/src/state/feed.dart b/packages/stream_feeds/lib/src/state/feed.dart index ed96075a..ae0c2c81 100644 --- a/packages/stream_feeds/lib/src/state/feed.dart +++ b/packages/stream_feeds/lib/src/state/feed.dart @@ -55,9 +55,9 @@ class Feed with Disposable { required this.feedsRepository, required this.pollsRepository, required this.capabilitiesRepository, - required MutableSharedEmitter eventsEmitter, + required this._eventsEmitter, required Stream onReconnectEmitter, - }) : _eventsEmitter = eventsEmitter { + }) { final fid = query.fid; _memberList = MemberList( diff --git a/packages/stream_feeds/lib/src/state/feed_list.dart b/packages/stream_feeds/lib/src/state/feed_list.dart index a9c7d84d..bdccbc49 100644 --- a/packages/stream_feeds/lib/src/state/feed_list.dart +++ b/packages/stream_feeds/lib/src/state/feed_list.dart @@ -26,8 +26,8 @@ class FeedList with Disposable { FeedList({ required this.query, required this.feedsRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = FeedListStateNotifier( initialState: const FeedListState(), ); diff --git a/packages/stream_feeds/lib/src/state/follow_list.dart b/packages/stream_feeds/lib/src/state/follow_list.dart index 7fb5e5c3..e5979aef 100644 --- a/packages/stream_feeds/lib/src/state/follow_list.dart +++ b/packages/stream_feeds/lib/src/state/follow_list.dart @@ -26,8 +26,8 @@ class FollowList with Disposable { FollowList({ required this.query, required this.feedsRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = FollowListStateNotifier( initialState: const FollowListState(), ); diff --git a/packages/stream_feeds/lib/src/state/member_list.dart b/packages/stream_feeds/lib/src/state/member_list.dart index 0b0ace12..d97b5f51 100644 --- a/packages/stream_feeds/lib/src/state/member_list.dart +++ b/packages/stream_feeds/lib/src/state/member_list.dart @@ -25,8 +25,8 @@ class MemberList extends Disposable { MemberList({ required this.query, required this.feedsRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = MemberListStateNotifier( initialState: const MemberListState(), ); diff --git a/packages/stream_feeds/lib/src/state/poll_list.dart b/packages/stream_feeds/lib/src/state/poll_list.dart index a99430a2..62be733f 100644 --- a/packages/stream_feeds/lib/src/state/poll_list.dart +++ b/packages/stream_feeds/lib/src/state/poll_list.dart @@ -27,8 +27,8 @@ class PollList with Disposable { required this.query, required this.currentUserId, required this.pollsRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = PollListStateNotifier( currentUserId: currentUserId, initialState: const PollListState(), diff --git a/packages/stream_feeds/lib/src/state/poll_vote_list.dart b/packages/stream_feeds/lib/src/state/poll_vote_list.dart index 86206fcb..f14efd4d 100644 --- a/packages/stream_feeds/lib/src/state/poll_vote_list.dart +++ b/packages/stream_feeds/lib/src/state/poll_vote_list.dart @@ -26,8 +26,8 @@ class PollVoteList with Disposable { PollVoteList({ required this.query, required this.pollsRepository, - required MutableSharedEmitter eventsEmitter, - }) : _eventsEmitter = eventsEmitter { + required this._eventsEmitter, + }) { _stateNotifier = PollVoteListStateNotifier( initialState: const PollVoteListState(), ); diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 91db782c..b1669ff8 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -16,7 +16,7 @@ repository: https://github.com/GetStream/stream-feeds-flutter # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.10.0 + sdk: ^3.12.0 dependencies: collection: ^1.18.0 diff --git a/packages/stream_feeds_test/pubspec.yaml b/packages/stream_feeds_test/pubspec.yaml index e65a0f4e..99c32b7b 100644 --- a/packages/stream_feeds_test/pubspec.yaml +++ b/packages/stream_feeds_test/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.1.0 publish_to: none # This package is not intended for publishing. environment: - sdk: ^3.10.0 + sdk: ^3.12.0 dependencies: collection: ^1.18.0 diff --git a/pubspec.yaml b/pubspec.yaml index f7216931..3899e6ca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_feed_flutter_workspace environment: - sdk: ^3.10.0 + sdk: ^3.12.0 dev_dependencies: melos: ^6.2.0 diff --git a/sample_app/lib/push/push_provider.dart b/sample_app/lib/push/push_provider.dart index ccf98beb..e210e92f 100644 --- a/sample_app/lib/push/push_provider.dart +++ b/sample_app/lib/push/push_provider.dart @@ -7,15 +7,13 @@ typedef TokenStreamProvider = Stream Function(); class PushProvider { const PushProvider.firebase({ required this.name, - TokenStreamProvider tokenStreamProvider = _firebaseTokenProvider, - }) : _tokenStreamProvider = tokenStreamProvider, - type = PushNotificationsProvider.firebase; + this._tokenStreamProvider = _firebaseTokenProvider, + }) : type = PushNotificationsProvider.firebase; const PushProvider.apn({ required this.name, - TokenStreamProvider tokenStreamProvider = _apnTokenProvider, - }) : _tokenStreamProvider = tokenStreamProvider, - type = PushNotificationsProvider.apn; + this._tokenStreamProvider = _apnTokenProvider, + }) : type = PushNotificationsProvider.apn; static Stream _firebaseTokenProvider() async* { final initialToken = await FirebaseMessaging.instance.getToken(); diff --git a/sample_app/lib/reconnect_providers/network_state_provider.dart b/sample_app/lib/reconnect_providers/network_state_provider.dart index 1d1592a5..b0e53dd3 100644 --- a/sample_app/lib/reconnect_providers/network_state_provider.dart +++ b/sample_app/lib/reconnect_providers/network_state_provider.dart @@ -7,8 +7,8 @@ import 'package:stream_feeds/stream_feeds.dart'; @LazySingleton(as: NetworkStateProvider) final class InternetStateProvider with Disposable implements NetworkStateProvider { InternetStateProvider({ - required InternetConnection checker, - }) : _checker = checker { + required this._checker, + }) { // Subscribe to the status changes. _connectionSubscription = _checker.onStatusChange.listen(_onStatusChange); } diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 6e934270..fd7ef710 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -5,7 +5,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 0.4.0 environment: - sdk: ^3.10.0 + sdk: ^3.12.0 dependencies: auto_route: ^11.0.0 From 4f0f61d5f5ac83bd891393ab91117079c9488144 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:51:38 +0200 Subject: [PATCH 18/31] docs(changelog): match the style the released sections use The unreleased section had grown six `###` groupings, two of them both headed `[BREAKING]`, and entries running to several clauses each. Released sections are a flat list of one-line bullets with an inline `[BREAKING]` prefix, so this is too. Trimmed to what someone upgrading acts on, dropping the reasoning behind each change and the account of what the old behaviour was. The rename table stays: twenty-odd aliases are the one thing here nobody can look up any other way. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/CHANGELOG.md | 60 +++++++++++------------------- 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index a0906119..f2037a39 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,38 +1,29 @@ ## Upcoming -### Improvements -- Guest users (`User.guest(id)`) now obtain a real JWT by calling `POST /api/v2/guest` during `connect()`, giving them a full authenticated session with WebSocket support. Previously guest users fell back to the anonymous token which prevented WS connectivity. The server assigns the id, so `client.user` is updated to match it, and the identity is established once — reconnecting resumes it rather than creating another guest. -- `disconnect()` now only closes the connection, leaving the client reusable: subscriptions to `events`, `stateUpdateEvents` and `connectionState` keep working across any number of `connect()`/`disconnect()` cycles. It previously closed them too, so a reconnected client emitted no further state updates and never reconnected automatically again. -- Added `dispose()`, which releases what `disconnect()` used to, along with the WebSocket client and the capabilities batcher. It is terminal: `connect()` throws a `StateError` afterwards, and calling it twice does nothing. An injected `feedsRestApi` or `wsProvider` is left alone, since the client does not own it. -- `connect()` takes a `connectWebSocket` flag. Pass `false` to authenticate without opening a WebSocket, for a client that only makes requests: no events are emitted, and a watched query is rejected, since watching requires a connection. An anonymous user always connects this way. -- A token the server refuses as expired now recovers on its own, reconnecting with one the `TokenProvider` issued afterwards, so a token expiring mid-session no longer leaves the client offline until the app calls `connect()` again. A static token has none to replace it, so the connection is left closed with an authentication failure rather than presenting the refused token again. -- `connect()` now throws a `ClientException` when a connection is already established or in progress, rather than returning without connecting, and the one it throws on failure now carries the underlying cause instead of only a close reason. - -- Added `FeedsConfig.logConfig`, which says how much the client reports and where those records go. Left out, the client installs nothing, so it stays silent and leaves the logger to whichever Stream SDK beside it configured one. Records carry an `SF:` tag, so a handler shared with another SDK can still tell them apart. - -### Bug fixes -- Fixed the HTTP logs carrying the `Authorization` header the request was signed with, which put the user's token in the console of every app that had logging on. -- Fixed `connect()` failing when called straight after `disconnect()`. `disconnect()` returned before the socket had closed, so the next `connect()` observed the pending closure and reported it as a failed connection. -- Fixed a failure to send the WebSocket authentication frame being ignored. The connection sat in `Authenticating` until it timed out; it now closes immediately with the real cause. - -### New fields -- Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. -- Added `friendReactionCount` and `friendReactions` fields to `ActivityData` to expose reactions from friends. -- Added `metrics` field to `ActivityData` for server-side activity metrics (impressions, clicks, etc.). -- Added `bookmarkCount` and `editedAt` fields to `CommentData`. -- Added `location` (`LocationCoordinate?`) field to `FeedData`. -- Added `createNotificationActivity`, `skipPush`, and `enrichOwnFields` optional flags to `FeedAddActivityRequest`. - -### WebSocket events -- `ActivityRestoredEvent` and `CommentRestoredEvent` are now handled: restored items are upserted back into feed/list state. - -### Deprecated — renamed types (backwards-compatible aliases added) -The following generated types were renamed in the underlying API. Deprecated `typedef` aliases -have been added so existing code continues to compile with a deprecation warning. Migrate to -the new names at your earliest convenience. +- [BREAKING] Raised the minimum Dart SDK to `^3.12.0`, which `stream_core` now requires. +- [BREAKING] `Ban` class removed. Replaced by `BanInfoResponse` which has a different field structure: `target` → `user`, `shadow: bool` (required) → `shadow: bool?` (optional), `channel` field removed. +- [BREAKING] `PollResponseData.votingVisibility` is now a required field (was optional in the old `Poll` class). Code constructing `Poll`/`PollResponseData` directly (e.g. in tests) must supply `votingVisibility`. +- [BREAKING] Changed `ActivityCommentList.state` getter return type from `StateNotifier` to `ActivityCommentListState` to be consistent with all other state classes. +- [BREAKING] The following types were removed from the public API. They belonged to video/call/chat functionality not relevant to the Feeds SDK and should not have been exported: `AudioSettingsResponse`, `BackstageSettingsResponse`, `BroadcastSettingsResponse`, `CallIngressResponse`, `CallParticipantResponse`, `CallSessionResponse`, `CallSettingsResponse`, `Channel`, `ChannelConfig`, `ChannelMember`, `ChannelMemberLookup`, `ChannelPushPreferences`, `CompositeRecordingResponse`, `ConfigOverrides`, `DeliveryReceipts`, `DenormalizedChannelFields`, `Device`, `EgressHlsResponse`, `EgressResponse`, `EgressRtmpResponse`, `FrameRecordingResponse`, `FrameRecordingSettingsResponse`, `GeofenceSettingsResponse`, `HlsSettingsResponse`, `IndividualRecordingResponse`, `IndividualRecordingSettingsResponse`, `IngressAudioEncodingResponse`, `IngressSettingsResponse`, `IngressSourceResponse`, `IngressVideoEncodingResponse`, `IngressVideoLayerResponse`, `LimitsSettingsResponse`, `Message`, `MessageReminder`, `ModerationActionConfig`, `NoiseCancellationSettings`, `PrivacySettings`, `RawRecordingResponse`, `RawRecordingSettingsResponse`, `ReadReceipts`, `RecordSettingsResponse`, `RingSettingsResponse`, `RtmpIngress`, `RtmpSettingsResponse`, `ScreensharingSettingsResponse`, `SessionSettingsResponse`, `SharedLocation`, `SpeechSegmentConfig`, `SrtIngress`, `TargetResolution`, `ThumbnailResponse`, `ThumbnailsSettingsResponse`, `TranscriptionSettingsResponse`, `TranslationSettings`, `TypingIndicators`, `UserMutedEvent`, `VideoSettingsResponse`, `WhipIngress`. +- Guest users (`User.guest(id)`) now obtain a real JWT during `connect()` instead of falling back to the anonymous token, so they get a full session with WebSocket support. The server assigns the id, so read it from `client.user` afterwards. +- Add `dispose()`, which releases the client for good. `disconnect()` now only closes the connection, leaving the client reusable with its existing subscriptions intact. +- Add a `connectWebSocket` flag to `connect()`. Pass `false` to authenticate without opening a WebSocket, for a client that only makes requests. +- Add `FeedsConfig.logConfig` to say how much the client reports and where those records go. Left out, the client stays silent. +- A token the server refuses as expired now recovers on its own, reconnecting with one the `TokenProvider` issued afterwards. +- `connect()` now throws a `ClientException` when a connection is already established or in progress, and the one it throws on failure carries the underlying cause. +- Add `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. +- Add `friendReactionCount` and `friendReactions` fields to `ActivityData` to expose reactions from friends. +- Add `metrics` field to `ActivityData` for server-side activity metrics. +- Add `bookmarkCount` and `editedAt` fields to `CommentData`. +- Add `location` field to `FeedData`. +- Add `createNotificationActivity`, `skipPush`, and `enrichOwnFields` flags to `FeedAddActivityRequest`. +- `ActivityRestoredEvent` and `CommentRestoredEvent` are now handled: restored items are upserted back into feed and list state. +- Fixed the HTTP logs carrying the `Authorization` header, which put the user's token in the console of every app that had logging on. +- Fixed `connect()` failing when called straight after `disconnect()`, which reported the pending closure as a failed connection. +- Fixed a failure to send the WebSocket authentication frame being ignored, which left the connection stuck until it timed out. +- Deprecated the generated types renamed in the underlying API. Aliases keep existing code compiling with a deprecation warning: | Old name | New name | -|---|---| | `FollowPair` | `UnfollowPair` | | `ActivityLocation` | `Location` | | `OwnUser` | `OwnUserResponse` | @@ -56,13 +47,6 @@ the new names at your earliest convenience. | `UnbanActionRequest` | `UnbanActionRequestPayload` | | `UnblockActionRequest` | `UnblockActionRequestPayload` | -### [BREAKING] - -- [BREAKING] `Ban` class removed. Replaced by `BanInfoResponse` which has a different field structure: `target` → `user`, `shadow: bool` (required) → `shadow: bool?` (optional), `channel` field removed. -- [BREAKING] `PollResponseData.votingVisibility` is now a required field (was optional in the old `Poll` class). Code constructing `Poll`/`PollResponseData` directly (e.g. in tests) must supply `votingVisibility`. -- [BREAKING] The following types were removed from the public API. They belonged to video/call/chat functionality not relevant to the Feeds SDK and should not have been exported: `AudioSettingsResponse`, `BackstageSettingsResponse`, `BroadcastSettingsResponse`, `CallIngressResponse`, `CallParticipantResponse`, `CallSessionResponse`, `CallSettingsResponse`, `Channel`, `ChannelConfig`, `ChannelMember`, `ChannelMemberLookup`, `ChannelPushPreferences`, `CompositeRecordingResponse`, `ConfigOverrides`, `DeliveryReceipts`, `DenormalizedChannelFields`, `Device`, `EgressHlsResponse`, `EgressResponse`, `EgressRtmpResponse`, `FrameRecordingResponse`, `FrameRecordingSettingsResponse`, `GeofenceSettingsResponse`, `HlsSettingsResponse`, `IndividualRecordingResponse`, `IndividualRecordingSettingsResponse`, `IngressAudioEncodingResponse`, `IngressSettingsResponse`, `IngressSourceResponse`, `IngressVideoEncodingResponse`, `IngressVideoLayerResponse`, `LimitsSettingsResponse`, `Message`, `MessageReminder`, `ModerationActionConfig`, `NoiseCancellationSettings`, `PrivacySettings`, `RawRecordingResponse`, `RawRecordingSettingsResponse`, `ReadReceipts`, `RecordSettingsResponse`, `RingSettingsResponse`, `RtmpIngress`, `RtmpSettingsResponse`, `ScreensharingSettingsResponse`, `SessionSettingsResponse`, `SharedLocation`, `SpeechSegmentConfig`, `SrtIngress`, `TargetResolution`, `ThumbnailResponse`, `ThumbnailsSettingsResponse`, `TranscriptionSettingsResponse`, `TranslationSettings`, `TypingIndicators`, `UserMutedEvent`, `VideoSettingsResponse`, `WhipIngress`. -- [BREAKING] Changed `ActivityCommentList.state` getter return type from `StateNotifier` to `ActivityCommentListState` to be consistent with all other state classes. - ## 0.5.1 - Added missing state updates for the websocket events. - Add appeal-related methods to moderation client: `appeal`, `getAppeal`, and `queryAppeals`. From 5d7fffe5ab1c1b6e9e1be6ea632d59d45e6337f4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:52:53 +0200 Subject: [PATCH 19/31] docs(changelog): use the section style stream_core uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the six ad-hoc groupings — two of them both headed `[BREAKING]` — with the four `stream_core` keeps: breaking changes, features, bug fixes, changed. The per-entry `[BREAKING]` prefix goes with them, since the heading already says it. Entries are one line each and trimmed to what someone upgrading acts on, dropping the reasoning behind each change and the account of what the old behaviour was. The rename table stays: twenty-odd aliases are the one thing here nobody can look up elsewhere. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/CHANGELOG.md | 55 ++++++++++++++++++------------ 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index f2037a39..658d500c 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,27 +1,38 @@ ## Upcoming -- [BREAKING] Raised the minimum Dart SDK to `^3.12.0`, which `stream_core` now requires. -- [BREAKING] `Ban` class removed. Replaced by `BanInfoResponse` which has a different field structure: `target` → `user`, `shadow: bool` (required) → `shadow: bool?` (optional), `channel` field removed. -- [BREAKING] `PollResponseData.votingVisibility` is now a required field (was optional in the old `Poll` class). Code constructing `Poll`/`PollResponseData` directly (e.g. in tests) must supply `votingVisibility`. -- [BREAKING] Changed `ActivityCommentList.state` getter return type from `StateNotifier` to `ActivityCommentListState` to be consistent with all other state classes. -- [BREAKING] The following types were removed from the public API. They belonged to video/call/chat functionality not relevant to the Feeds SDK and should not have been exported: `AudioSettingsResponse`, `BackstageSettingsResponse`, `BroadcastSettingsResponse`, `CallIngressResponse`, `CallParticipantResponse`, `CallSessionResponse`, `CallSettingsResponse`, `Channel`, `ChannelConfig`, `ChannelMember`, `ChannelMemberLookup`, `ChannelPushPreferences`, `CompositeRecordingResponse`, `ConfigOverrides`, `DeliveryReceipts`, `DenormalizedChannelFields`, `Device`, `EgressHlsResponse`, `EgressResponse`, `EgressRtmpResponse`, `FrameRecordingResponse`, `FrameRecordingSettingsResponse`, `GeofenceSettingsResponse`, `HlsSettingsResponse`, `IndividualRecordingResponse`, `IndividualRecordingSettingsResponse`, `IngressAudioEncodingResponse`, `IngressSettingsResponse`, `IngressSourceResponse`, `IngressVideoEncodingResponse`, `IngressVideoLayerResponse`, `LimitsSettingsResponse`, `Message`, `MessageReminder`, `ModerationActionConfig`, `NoiseCancellationSettings`, `PrivacySettings`, `RawRecordingResponse`, `RawRecordingSettingsResponse`, `ReadReceipts`, `RecordSettingsResponse`, `RingSettingsResponse`, `RtmpIngress`, `RtmpSettingsResponse`, `ScreensharingSettingsResponse`, `SessionSettingsResponse`, `SharedLocation`, `SpeechSegmentConfig`, `SrtIngress`, `TargetResolution`, `ThumbnailResponse`, `ThumbnailsSettingsResponse`, `TranscriptionSettingsResponse`, `TranslationSettings`, `TypingIndicators`, `UserMutedEvent`, `VideoSettingsResponse`, `WhipIngress`. -- Guest users (`User.guest(id)`) now obtain a real JWT during `connect()` instead of falling back to the anonymous token, so they get a full session with WebSocket support. The server assigns the id, so read it from `client.user` afterwards. -- Add `dispose()`, which releases the client for good. `disconnect()` now only closes the connection, leaving the client reusable with its existing subscriptions intact. -- Add a `connectWebSocket` flag to `connect()`. Pass `false` to authenticate without opening a WebSocket, for a client that only makes requests. -- Add `FeedsConfig.logConfig` to say how much the client reports and where those records go. Left out, the client stays silent. -- A token the server refuses as expired now recovers on its own, reconnecting with one the `TokenProvider` issued afterwards. -- `connect()` now throws a `ClientException` when a connection is already established or in progress, and the one it throws on failure carries the underlying cause. -- Add `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. -- Add `friendReactionCount` and `friendReactions` fields to `ActivityData` to expose reactions from friends. -- Add `metrics` field to `ActivityData` for server-side activity metrics. -- Add `bookmarkCount` and `editedAt` fields to `CommentData`. -- Add `location` field to `FeedData`. -- Add `createNotificationActivity`, `skipPush`, and `enrichOwnFields` flags to `FeedAddActivityRequest`. -- `ActivityRestoredEvent` and `CommentRestoredEvent` are now handled: restored items are upserted back into feed and list state. -- Fixed the HTTP logs carrying the `Authorization` header, which put the user's token in the console of every app that had logging on. -- Fixed `connect()` failing when called straight after `disconnect()`, which reported the pending closure as a failed connection. -- Fixed a failure to send the WebSocket authentication frame being ignored, which left the connection stuck until it timed out. -- Deprecated the generated types renamed in the underlying API. Aliases keep existing code compiling with a deprecation warning: +### 💥 BREAKING CHANGES + +- Raised the minimum Dart SDK to `^3.12.0`, which `stream_core` now requires +- `Ban` removed, replaced by `BanInfoResponse`: `target` is now `user`, `shadow` is optional rather than required, and `channel` is gone +- `PollResponseData.votingVisibility` is now required, so anything constructing one directly must supply it +- `ActivityCommentList.state` returns `ActivityCommentListState` rather than `StateNotifier`, matching the other state classes +- Removed the video, call and chat types that were never relevant to Feeds and should not have been exported: `AudioSettingsResponse`, `BackstageSettingsResponse`, `BroadcastSettingsResponse`, `CallIngressResponse`, `CallParticipantResponse`, `CallSessionResponse`, `CallSettingsResponse`, `Channel`, `ChannelConfig`, `ChannelMember`, `ChannelMemberLookup`, `ChannelPushPreferences`, `CompositeRecordingResponse`, `ConfigOverrides`, `DeliveryReceipts`, `DenormalizedChannelFields`, `Device`, `EgressHlsResponse`, `EgressResponse`, `EgressRtmpResponse`, `FrameRecordingResponse`, `FrameRecordingSettingsResponse`, `GeofenceSettingsResponse`, `HlsSettingsResponse`, `IndividualRecordingResponse`, `IndividualRecordingSettingsResponse`, `IngressAudioEncodingResponse`, `IngressSettingsResponse`, `IngressSourceResponse`, `IngressVideoEncodingResponse`, `IngressVideoLayerResponse`, `LimitsSettingsResponse`, `Message`, `MessageReminder`, `ModerationActionConfig`, `NoiseCancellationSettings`, `PrivacySettings`, `RawRecordingResponse`, `RawRecordingSettingsResponse`, `ReadReceipts`, `RecordSettingsResponse`, `RingSettingsResponse`, `RtmpIngress`, `RtmpSettingsResponse`, `ScreensharingSettingsResponse`, `SessionSettingsResponse`, `SharedLocation`, `SpeechSegmentConfig`, `SrtIngress`, `TargetResolution`, `ThumbnailResponse`, `ThumbnailsSettingsResponse`, `TranscriptionSettingsResponse`, `TranslationSettings`, `TypingIndicators`, `UserMutedEvent`, `VideoSettingsResponse`, `WhipIngress` + +### ✨ Features + +- Guest users (`User.guest(id)`) now obtain a real JWT during `connect()` instead of falling back to the anonymous token, so they get a full session with WebSocket support; the server assigns the id, so read it from `client.user` afterwards +- Added `StreamFeedsClient.dispose`, which releases the client for good; `connect` throws a `StateError` afterwards +- Added a `connectWebSocket` flag to `connect`, for a client that only makes requests: it authenticates without opening a WebSocket +- Added `FeedsConfig.logConfig`, which says how much the client reports and where those records go; left out, the client stays silent +- Added `isRead` and `isSeen` to `ActivityData` and `AggregatedActivityData`, for notification-feed read/seen state +- Added `friendReactionCount` and `friendReactions` to `ActivityData`, exposing reactions from friends +- Added `metrics` to `ActivityData`, for server-side activity metrics +- Added `bookmarkCount` and `editedAt` to `CommentData`, and `location` to `FeedData` +- Added `createNotificationActivity`, `skipPush` and `enrichOwnFields` flags to `FeedAddActivityRequest` +- `ActivityRestoredEvent` and `CommentRestoredEvent` are now handled, upserting the restored item back into feed and list state + +### 🐛 Bug Fixes + +- Fixed the HTTP logs carrying the `Authorization` header, which put the user's token in the console of every app that had logging on +- Fixed `connect` failing when called straight after `disconnect`, which reported the pending closure as a failed connection +- Fixed a failure to send the WebSocket authentication frame being ignored, which left the connection stuck until it timed out + +### 🔄 Changed + +- `disconnect` now only closes the connection, leaving the client reusable with its existing subscriptions intact; releasing it is `dispose` +- A token the server refuses as expired now recovers on its own, reconnecting with one the `TokenProvider` issued afterwards +- `connect` throws a `ClientException` when a connection is already established or in progress, and the one it throws on failure carries the underlying cause +- Deprecated the generated types renamed in the underlying API; aliases keep existing code compiling with a deprecation warning: | Old name | New name | | `FollowPair` | `UnfollowPair` | From f9e62d438fbfe75dfe458dcc83c3eb918cbd42a1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:54:34 +0200 Subject: [PATCH 20/31] docs(changelog): drop what a reader cannot act on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries described how things work rather than what changed for whoever reads them: a guest obtaining "a real JWT" instead of "falling back to the anonymous token", a send failing on "the WebSocket authentication frame", a restored item being "upserted", types renamed "in the underlying API". None of that is reachable from an app, and the wire and the server are not the reader's to think about. What each says now is the effect and what to do: a guest connects like any other user and has its id assigned, a connection that cannot authenticate fails with the reason rather than hanging, a restored activity reappears. Also drops the reasoning left on three entries — why the SDK floor moved, why those types should never have shipped — which explains the change rather than stating it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/CHANGELOG.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 658d500c..fe30b714 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -2,37 +2,37 @@ ### 💥 BREAKING CHANGES -- Raised the minimum Dart SDK to `^3.12.0`, which `stream_core` now requires +- Raised the minimum Dart SDK to `^3.12.0` - `Ban` removed, replaced by `BanInfoResponse`: `target` is now `user`, `shadow` is optional rather than required, and `channel` is gone - `PollResponseData.votingVisibility` is now required, so anything constructing one directly must supply it - `ActivityCommentList.state` returns `ActivityCommentListState` rather than `StateNotifier`, matching the other state classes -- Removed the video, call and chat types that were never relevant to Feeds and should not have been exported: `AudioSettingsResponse`, `BackstageSettingsResponse`, `BroadcastSettingsResponse`, `CallIngressResponse`, `CallParticipantResponse`, `CallSessionResponse`, `CallSettingsResponse`, `Channel`, `ChannelConfig`, `ChannelMember`, `ChannelMemberLookup`, `ChannelPushPreferences`, `CompositeRecordingResponse`, `ConfigOverrides`, `DeliveryReceipts`, `DenormalizedChannelFields`, `Device`, `EgressHlsResponse`, `EgressResponse`, `EgressRtmpResponse`, `FrameRecordingResponse`, `FrameRecordingSettingsResponse`, `GeofenceSettingsResponse`, `HlsSettingsResponse`, `IndividualRecordingResponse`, `IndividualRecordingSettingsResponse`, `IngressAudioEncodingResponse`, `IngressSettingsResponse`, `IngressSourceResponse`, `IngressVideoEncodingResponse`, `IngressVideoLayerResponse`, `LimitsSettingsResponse`, `Message`, `MessageReminder`, `ModerationActionConfig`, `NoiseCancellationSettings`, `PrivacySettings`, `RawRecordingResponse`, `RawRecordingSettingsResponse`, `ReadReceipts`, `RecordSettingsResponse`, `RingSettingsResponse`, `RtmpIngress`, `RtmpSettingsResponse`, `ScreensharingSettingsResponse`, `SessionSettingsResponse`, `SharedLocation`, `SpeechSegmentConfig`, `SrtIngress`, `TargetResolution`, `ThumbnailResponse`, `ThumbnailsSettingsResponse`, `TranscriptionSettingsResponse`, `TranslationSettings`, `TypingIndicators`, `UserMutedEvent`, `VideoSettingsResponse`, `WhipIngress` +- Removed types that were never part of the Feeds API: `AudioSettingsResponse`, `BackstageSettingsResponse`, `BroadcastSettingsResponse`, `CallIngressResponse`, `CallParticipantResponse`, `CallSessionResponse`, `CallSettingsResponse`, `Channel`, `ChannelConfig`, `ChannelMember`, `ChannelMemberLookup`, `ChannelPushPreferences`, `CompositeRecordingResponse`, `ConfigOverrides`, `DeliveryReceipts`, `DenormalizedChannelFields`, `Device`, `EgressHlsResponse`, `EgressResponse`, `EgressRtmpResponse`, `FrameRecordingResponse`, `FrameRecordingSettingsResponse`, `GeofenceSettingsResponse`, `HlsSettingsResponse`, `IndividualRecordingResponse`, `IndividualRecordingSettingsResponse`, `IngressAudioEncodingResponse`, `IngressSettingsResponse`, `IngressSourceResponse`, `IngressVideoEncodingResponse`, `IngressVideoLayerResponse`, `LimitsSettingsResponse`, `Message`, `MessageReminder`, `ModerationActionConfig`, `NoiseCancellationSettings`, `PrivacySettings`, `RawRecordingResponse`, `RawRecordingSettingsResponse`, `ReadReceipts`, `RecordSettingsResponse`, `RingSettingsResponse`, `RtmpIngress`, `RtmpSettingsResponse`, `ScreensharingSettingsResponse`, `SessionSettingsResponse`, `SharedLocation`, `SpeechSegmentConfig`, `SrtIngress`, `TargetResolution`, `ThumbnailResponse`, `ThumbnailsSettingsResponse`, `TranscriptionSettingsResponse`, `TranslationSettings`, `TypingIndicators`, `UserMutedEvent`, `VideoSettingsResponse`, `WhipIngress` ### ✨ Features -- Guest users (`User.guest(id)`) now obtain a real JWT during `connect()` instead of falling back to the anonymous token, so they get a full session with WebSocket support; the server assigns the id, so read it from `client.user` afterwards +- Guest users (`User.guest(id)`) can now connect, with the same read and write access and the same real-time updates as a regular user; their id is assigned on connect, so read it from `client.user` afterwards - Added `StreamFeedsClient.dispose`, which releases the client for good; `connect` throws a `StateError` afterwards -- Added a `connectWebSocket` flag to `connect`, for a client that only makes requests: it authenticates without opening a WebSocket +- Added a `connectWebSocket` flag to `connect`. Pass `false` for a client that only makes requests: no real-time updates arrive, and a watched query is rejected - Added `FeedsConfig.logConfig`, which says how much the client reports and where those records go; left out, the client stays silent - Added `isRead` and `isSeen` to `ActivityData` and `AggregatedActivityData`, for notification-feed read/seen state - Added `friendReactionCount` and `friendReactions` to `ActivityData`, exposing reactions from friends -- Added `metrics` to `ActivityData`, for server-side activity metrics +- Added `metrics` to `ActivityData`, carrying impressions, clicks and similar - Added `bookmarkCount` and `editedAt` to `CommentData`, and `location` to `FeedData` - Added `createNotificationActivity`, `skipPush` and `enrichOwnFields` flags to `FeedAddActivityRequest` -- `ActivityRestoredEvent` and `CommentRestoredEvent` are now handled, upserting the restored item back into feed and list state +- A restored activity or comment now reappears in feed and list state, through `ActivityRestoredEvent` and `CommentRestoredEvent` ### 🐛 Bug Fixes -- Fixed the HTTP logs carrying the `Authorization` header, which put the user's token in the console of every app that had logging on -- Fixed `connect` failing when called straight after `disconnect`, which reported the pending closure as a failed connection -- Fixed a failure to send the WebSocket authentication frame being ignored, which left the connection stuck until it timed out +- Fixed the client logging the user's token, which put it in the console of every app that had logging on +- Fixed `connect` failing when called straight after `disconnect` +- Fixed a connection that could not authenticate hanging until it timed out, rather than failing with the reason ### 🔄 Changed - `disconnect` now only closes the connection, leaving the client reusable with its existing subscriptions intact; releasing it is `dispose` -- A token the server refuses as expired now recovers on its own, reconnecting with one the `TokenProvider` issued afterwards +- An expired token now recovers on its own: the connection comes back with one the `TokenProvider` issued afterwards, without the app doing anything - `connect` throws a `ClientException` when a connection is already established or in progress, and the one it throws on failure carries the underlying cause -- Deprecated the generated types renamed in the underlying API; aliases keep existing code compiling with a deprecation warning: +- Renamed the types below. The old names still compile, with a deprecation warning: | Old name | New name | | `FollowPair` | `UnfollowPair` | From 3313f4273bc7dca812c27dfc0b1fc3f6259fde1d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:56:06 +0200 Subject: [PATCH 21/31] docs(changelog): stop listing what the compiler already names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two entries carried inventories: 58 removed type names, and a 21-row table of renames. Neither reaches a reader who does not already have the name in front of them — a removed type is an undefined-name error, and every alias is annotated `@Deprecated('Renamed to X. Migrate to X.')`, so the analyzer names the replacement at the call site and `dart fix --apply` migrates it. What was left to say is the category and what to do, which is one line each. The section is down from 7.1k to 6.0k characters. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/CHANGELOG.md | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index fe30b714..0cbf36f1 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -6,7 +6,7 @@ - `Ban` removed, replaced by `BanInfoResponse`: `target` is now `user`, `shadow` is optional rather than required, and `channel` is gone - `PollResponseData.votingVisibility` is now required, so anything constructing one directly must supply it - `ActivityCommentList.state` returns `ActivityCommentListState` rather than `StateNotifier`, matching the other state classes -- Removed types that were never part of the Feeds API: `AudioSettingsResponse`, `BackstageSettingsResponse`, `BroadcastSettingsResponse`, `CallIngressResponse`, `CallParticipantResponse`, `CallSessionResponse`, `CallSettingsResponse`, `Channel`, `ChannelConfig`, `ChannelMember`, `ChannelMemberLookup`, `ChannelPushPreferences`, `CompositeRecordingResponse`, `ConfigOverrides`, `DeliveryReceipts`, `DenormalizedChannelFields`, `Device`, `EgressHlsResponse`, `EgressResponse`, `EgressRtmpResponse`, `FrameRecordingResponse`, `FrameRecordingSettingsResponse`, `GeofenceSettingsResponse`, `HlsSettingsResponse`, `IndividualRecordingResponse`, `IndividualRecordingSettingsResponse`, `IngressAudioEncodingResponse`, `IngressSettingsResponse`, `IngressSourceResponse`, `IngressVideoEncodingResponse`, `IngressVideoLayerResponse`, `LimitsSettingsResponse`, `Message`, `MessageReminder`, `ModerationActionConfig`, `NoiseCancellationSettings`, `PrivacySettings`, `RawRecordingResponse`, `RawRecordingSettingsResponse`, `ReadReceipts`, `RecordSettingsResponse`, `RingSettingsResponse`, `RtmpIngress`, `RtmpSettingsResponse`, `ScreensharingSettingsResponse`, `SessionSettingsResponse`, `SharedLocation`, `SpeechSegmentConfig`, `SrtIngress`, `TargetResolution`, `ThumbnailResponse`, `ThumbnailsSettingsResponse`, `TranscriptionSettingsResponse`, `TranslationSettings`, `TypingIndicators`, `UserMutedEvent`, `VideoSettingsResponse`, `WhipIngress` +- Removed the call, recording, streaming and chat types that were never part of the Feeds API ### ✨ Features @@ -32,31 +32,7 @@ - `disconnect` now only closes the connection, leaving the client reusable with its existing subscriptions intact; releasing it is `dispose` - An expired token now recovers on its own: the connection comes back with one the `TokenProvider` issued afterwards, without the app doing anything - `connect` throws a `ClientException` when a connection is already established or in progress, and the one it throws on failure carries the underlying cause -- Renamed the types below. The old names still compile, with a deprecation warning: - -| Old name | New name | -| `FollowPair` | `UnfollowPair` | -| `ActivityLocation` | `Location` | -| `OwnUser` | `OwnUserResponse` | -| `UserMute` | `UserMuteResponse` | -| `Poll` | `PollResponseData` | -| `PollOption` | `PollOptionResponseData` | -| `PollVote` | `PollVoteResponseData` | -| `BanActionRequest` | `BanActionRequestPayload` | -| `BanActionRequestDeleteMessages` | `BanActionRequestPayloadDeleteMessages` | -| `BlockActionRequest` | `BlockActionRequestPayload` | -| `ShadowBlockActionRequest` | `ShadowBlockActionRequestPayload` | -| `CustomActionRequest` | `CustomActionRequestPayload` | -| `DeleteUserRequest` | `DeleteUserRequestPayload` | -| `DeleteActivityRequest` | `DeleteActivityRequestPayload` | -| `DeleteCommentRequest` | `DeleteCommentRequestPayload` | -| `DeleteReactionRequest` | `DeleteReactionRequestPayload` | -| `DeleteMessageRequest` | `DeleteMessageRequestPayload` | -| `MarkReviewedRequest` | `MarkReviewedRequestPayload` | -| `RejectAppealRequest` | `RejectAppealRequestPayload` | -| `RestoreActionRequest` | `RestoreActionRequestPayload` | -| `UnbanActionRequest` | `UnbanActionRequestPayload` | -| `UnblockActionRequest` | `UnblockActionRequestPayload` | +- Renamed several generated types. The old names still compile, with a deprecation warning naming the replacement, and `dart fix --apply` migrates them ## 0.5.1 - Added missing state updates for the websocket events. From 985a2090fa7246121d7f4bd2958f80a0ba77782e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:57:42 +0200 Subject: [PATCH 22/31] docs(changelog): put the rename table back, and make it a table Restores the twenty-one renames I collapsed a commit ago: the annotation names the replacement at the call site, but the table is what someone reads before they compile, to see whether the upgrade touches them at all. It also lost its `|---|---|` separator when this section was first restructured, so it had been rendering as five lines of pipes rather than a table. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/CHANGELOG.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 0cbf36f1..0ae9aeab 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -32,7 +32,32 @@ - `disconnect` now only closes the connection, leaving the client reusable with its existing subscriptions intact; releasing it is `dispose` - An expired token now recovers on its own: the connection comes back with one the `TokenProvider` issued afterwards, without the app doing anything - `connect` throws a `ClientException` when a connection is already established or in progress, and the one it throws on failure carries the underlying cause -- Renamed several generated types. The old names still compile, with a deprecation warning naming the replacement, and `dart fix --apply` migrates them +- Renamed the types below. The old names still compile, with a deprecation warning, and `dart fix --apply` migrates them: + +| Old name | New name | +|---|---| +| `FollowPair` | `UnfollowPair` | +| `ActivityLocation` | `Location` | +| `OwnUser` | `OwnUserResponse` | +| `UserMute` | `UserMuteResponse` | +| `Poll` | `PollResponseData` | +| `PollOption` | `PollOptionResponseData` | +| `PollVote` | `PollVoteResponseData` | +| `BanActionRequest` | `BanActionRequestPayload` | +| `BanActionRequestDeleteMessages` | `BanActionRequestPayloadDeleteMessages` | +| `BlockActionRequest` | `BlockActionRequestPayload` | +| `ShadowBlockActionRequest` | `ShadowBlockActionRequestPayload` | +| `CustomActionRequest` | `CustomActionRequestPayload` | +| `DeleteUserRequest` | `DeleteUserRequestPayload` | +| `DeleteActivityRequest` | `DeleteActivityRequestPayload` | +| `DeleteCommentRequest` | `DeleteCommentRequestPayload` | +| `DeleteReactionRequest` | `DeleteReactionRequestPayload` | +| `DeleteMessageRequest` | `DeleteMessageRequestPayload` | +| `MarkReviewedRequest` | `MarkReviewedRequestPayload` | +| `RejectAppealRequest` | `RejectAppealRequestPayload` | +| `RestoreActionRequest` | `RestoreActionRequestPayload` | +| `UnbanActionRequest` | `UnbanActionRequestPayload` | +| `UnblockActionRequest` | `UnblockActionRequestPayload` | ## 0.5.1 - Added missing state updates for the websocket events. From 21049a6b094bc84b73fbde9809b2a6a873bd0b05 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 05:02:27 +0200 Subject: [PATCH 23/31] docs: release the client each authentication snippet builds Every example connected and returned, so anything copied from them kept a WebSocket, a recovery handler and an event subscription for the life of the process. They call `dispose` now, which is the terminal release; the first says so, and points at `disconnect` for the case where the client is used again. Also names the right package in the pubspec: the dependency pinned there is `stream_core`, not `stream_core_flutter`. Co-Authored-By: Claude Opus 5 (1M context) --- docs/code_snippets/02_02_authentication.dart | 11 +++++++++++ packages/stream_feeds/pubspec.yaml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/code_snippets/02_02_authentication.dart b/docs/code_snippets/02_02_authentication.dart index 69ca519f..eb75de38 100644 --- a/docs/code_snippets/02_02_authentication.dart +++ b/docs/code_snippets/02_02_authentication.dart @@ -8,6 +8,10 @@ Future regularUserLogin() async { tokenProvider: TokenProvider.static(UserToken('')), ); await client.connect(); + + // Terminal, and what a real app calls when it is done with the client for good — + // on sign-out, say. Use `disconnect` to close the connection and keep the client. + await client.dispose(); } Future dynamicTokenProvider() async { @@ -23,6 +27,7 @@ Future dynamicTokenProvider() async { }), ); await client.connect(); + await client.dispose(); } // Placeholder for your server token fetch @@ -42,6 +47,8 @@ Future guestUserLogin() async { // rather than reusing the id you asked for. final feed = client.feed(group: 'user', id: client.user.id); await feed.getOrCreate(); + + await client.dispose(); } Future anonymousUserLogin() async { @@ -61,6 +68,8 @@ Future anonymousUserLogin() async { ), ); await feed.getOrCreate(); + + await client.dispose(); } Future requestOnlyLogin() async { @@ -81,4 +90,6 @@ Future requestOnlyLogin() async { ), ); await feed.getOrCreate(); + + await client.dispose(); } diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index b1669ff8..438c4772 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: state_notifier: ^1.0.0 stream_core: # The ignore below silences `invalid_dependency` because we occasionally - # pin stream_core_flutter to a git ref to iterate on it alongside this + # pin stream_core to a git ref to iterate on it alongside this # SDK between its releases. # # **Note:** Before publishing stream_feeds, this MUST be swapped From 70cd0a30eb4b1a7761dbf8f3a5bcc7ad35f9634c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 15:26:28 +0200 Subject: [PATCH 24/31] ci(repo): resolve dependencies one package at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five packages ran `pub get` at once, and all of them resolve the same `stream_core` git dependency, so they raced each other over the one cache entry pub keeps for it. That is the intermittent `Bootstrap Workspace` failure on the analyze job — the jobs that only bootstrap `stream_**,example` hit it less often because fewer packages contend. Sequential is slower by a few seconds and does not race. Co-Authored-By: Claude Opus 5 (1M context) --- melos.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/melos.yaml b/melos.yaml index d5f90171..4c6329a8 100644 --- a/melos.yaml +++ b/melos.yaml @@ -12,6 +12,9 @@ packages: command: bootstrap: + # Run `pub get` sequentially to avoid races on the shared git-dep cache. + runPubGetInParallel: false + # Dart and Flutter environment used in the project. environment: sdk: ^3.12.0 From e4144a1d48eecbdb7da3122ea51aebc8e8fe96b7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 16:23:30 +0200 Subject: [PATCH 25/31] chore(deps): pin stream_core to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both core PRs landed — #160 as 813026f and #164 as 680e93a — so the branch ref this was pinned to is no longer the place to read them from. Still a git ref rather than a version constraint: core has not been released yet, and the comment above it still applies before stream_feeds can publish. Co-Authored-By: Claude Opus 5 (1M context) --- melos.yaml | 2 +- packages/stream_feeds/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/melos.yaml b/melos.yaml index 4c6329a8..3c10d2ed 100644 --- a/melos.yaml +++ b/melos.yaml @@ -53,7 +53,7 @@ command: stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 8260248b8fa08d8eb0eee66afa369b3a1a1f5aef + ref: 680e93a8fc1981293776dbdcd11a5f354d2d881b path: packages/stream_core video_player: ^2.10.0 uuid: ^4.5.1 diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 438c4772..c3ace292 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 8260248b8fa08d8eb0eee66afa369b3a1a1f5aef + ref: 680e93a8fc1981293776dbdcd11a5f354d2d881b path: packages/stream_core uuid: ^4.5.1 From d9a0786527be7b8385c15b0a1005ed4ebfd74e29 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 16:26:19 +0200 Subject: [PATCH 26/31] feat(llc): report request headers, and stop claiming the token stays out of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `requestHeader: true` puts the request's headers in the record, which is what makes the logs worth reading — and includes `Authorization`, which core's interceptor does not redact. So the changelog line saying the token no longer reaches the console goes: it is true only while nothing has asked for records. What replaces it is on the `logConfig` entry, where someone deciding whether to switch logging on will read it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/CHANGELOG.md | 3 +-- packages/stream_feeds/lib/src/client/feeds_client_impl.dart | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 5cfe2d15..86c81a40 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -13,7 +13,7 @@ - Guest users (`User.guest(id)`) can now connect, with the same read and write access and the same real-time updates as a regular user; their id is assigned on connect, so read it from `client.user` afterwards - Added `StreamFeedsClient.dispose`, which releases the client for good; `connect` throws a `StateError` afterwards - Added a `connectWebSocket` flag to `connect`. Pass `false` for a client that only makes requests: no real-time updates arrive, and a watched query is rejected -- Added `FeedsConfig.logConfig`, which says how much the client reports and where those records go; left out, the client stays silent +- Added `FeedsConfig.logConfig`, which says how much the client reports and where those records go; left out, the client stays silent. Records include the `Authorization` header, so weigh what reads them - Added `isRead` and `isSeen` to `ActivityData` and `AggregatedActivityData`, for notification-feed read/seen state - Added `friendReactionCount` and `friendReactions` to `ActivityData`, exposing reactions from friends - Added `metrics` to `ActivityData`, carrying impressions, clicks and similar @@ -26,7 +26,6 @@ ### 🐛 Bug Fixes -- Fixed the client logging the user's token, which put it in the console of every app that had logging on - Fixed `connect` failing when called straight after `disconnect` - Fixed a connection that could not authenticate hanging until it timed out, rather than failing with the reason diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 658650af..9f904236 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -149,7 +149,7 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { if (user.type != .anonymous) connectionIdInterceptor, AuthInterceptor(tag: 'SF:HttpAuth', client, _tokenManager), const ApiErrorInterceptor(), - LoggingInterceptor(tag: 'SF:Http'), + LoggingInterceptor(tag: 'SF:Http', requestHeader: true), ]), ); From 8317b93696fc54096b443a4340e30cb070006ffd Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 16:27:24 +0200 Subject: [PATCH 27/31] style(llc): pass the interceptors their positional arguments first Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/lib/src/client/feeds_client_impl.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 9f904236..037fa51e 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -147,9 +147,9 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { ApiKeyInterceptor(apiKey), HeadersInterceptor(_systemEnvironmentManager), if (user.type != .anonymous) connectionIdInterceptor, - AuthInterceptor(tag: 'SF:HttpAuth', client, _tokenManager), + AuthInterceptor(client, _tokenManager, tag: 'SF:HttpAuth'), const ApiErrorInterceptor(), - LoggingInterceptor(tag: 'SF:Http', requestHeader: true), + LoggingInterceptor(requestHeader: true, tag: 'SF:Http'), ]), ); From c984fc23a6b06d3ccda25e4957d293be613f1b12 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 16:34:16 +0200 Subject: [PATCH 28/31] docs: document the logging API the client actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class doc showed `logPriority` as a constructor argument. There is no such argument — logging is configured through `FeedsConfig.logConfig` — so the example did not compile if anyone copied it, and the prose around it described settings that do not exist. The logging snippets now release the client they build, as the authentication ones already do, and the background handler logs at debug priority only in debug builds: it runs in an isolate of its own and was configuring the logger for every build, then writing notification titles and bodies into device logs. Co-Authored-By: Claude Opus 5 (1M context) --- docs/code_snippets/12_01_logging.dart | 8 ++++++++ packages/stream_feeds/lib/src/feeds_client.dart | 12 +++++++----- .../notification_background_handler.dart | 5 ++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/code_snippets/12_01_logging.dart b/docs/code_snippets/12_01_logging.dart index d8c4ad97..f5cec41d 100644 --- a/docs/code_snippets/12_01_logging.dart +++ b/docs/code_snippets/12_01_logging.dart @@ -12,6 +12,10 @@ Future seeWhatTheClientIsDoing() async { ), ); await client.connect(); + + // Terminal, and what a real app calls when it is done with the client for good. Use `disconnect` + // to close the connection and keep the client. + await client.dispose(); } Future sendRecordsSomewhereElse() async { @@ -28,6 +32,7 @@ Future sendRecordsSomewhereElse() async { ), ); await client.connect(); + await client.dispose(); } Future keepTheConsoleAsWell() async { @@ -49,6 +54,7 @@ Future keepTheConsoleAsWell() async { ), ); await client.connect(); + await client.dispose(); } Future onlyWhileDeveloping() async { @@ -65,6 +71,7 @@ Future onlyWhileDeveloping() async { ), ); await client.connect(); + await client.dispose(); } Future turnUpOneSubsystem() async { @@ -85,6 +92,7 @@ Future turnUpOneSubsystem() async { ), ); await client.connect(); + await client.dispose(); } // Placeholder for wherever your app sends its diagnostics. diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index 967b984a..2005bb48 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -80,19 +80,21 @@ export 'client/moderation_client.dart'; /// /// ## Logging /// -/// Pass `logPriority` to see what the client is doing, and `logHandler` to say where those -/// records go. Left alone, the client writes nothing: +/// Pass a [StreamLogConfig] to say how much the client reports and where those records go. Left +/// out, the client writes nothing: /// /// ```dart /// final client = StreamFeedsClient( /// apiKey: 'your-api-key', /// user: user, -/// logPriority: StreamLogPriority.debug, +/// config: const FeedsConfig( +/// logConfig: StreamLogConfig(priority: StreamLogPriority.debug), +/// ), /// ); /// ``` /// -/// Both settings are shared with every other Stream SDK in the process, so passing them decides -/// for those too. Every record this client writes is tagged `SF:`, which is what tells them apart +/// The logger is shared with every other Stream SDK in the process, so passing one decides for +/// those too. Every record this client writes is tagged `SF:`, which is what tells them apart /// from another SDK's in the same log. /// /// Some of those records carry the `Authorization` header, so weigh what reads them. diff --git a/sample_app/lib/notification/notification_background_handler.dart b/sample_app/lib/notification/notification_background_handler.dart index 87923600..3e7d16fa 100644 --- a/sample_app/lib/notification/notification_background_handler.dart +++ b/sample_app/lib/notification/notification_background_handler.dart @@ -1,4 +1,5 @@ import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:stream_feeds/stream_feeds.dart'; import '../core/di/di_initializer.dart'; @@ -29,7 +30,9 @@ Future onBackgroundMessageHandler(RemoteMessage message) async { // A background message runs in an isolate of its own, which shares no statics with the app, so // the logger is set up again here rather than by whatever configured it there. - StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug)); + StreamLogger.configure( + const StreamLogConfig(priority: kDebugMode ? StreamLogPriority.debug : StreamLogPriority.none), + ); _logger.d(() => '📨 Background message received: ${notification.type}'); _logger.d(() => '📨 Title: ${notification.title}'); _logger.d(() => '📨 Body: ${notification.body}'); From 338f52af68b91c0d7358b9fa486e37d996fce07d Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 25 Aug 2026 17:32:14 +0200 Subject: [PATCH 29/31] fix docs mistakes and minor improvements --- .../lib/src/client/feeds_client_impl.dart | 27 +++++++- .../stream_feeds/lib/src/feeds_client.dart | 28 ++++++++- .../test/client/feeds_client_test.dart | 34 ++++++++++ .../state/activity_comment_list_test.dart | 12 ++-- .../test/state/comment_reply_list_test.dart | 62 +++++++++---------- .../lib/src/testers/base_tester.dart | 2 +- .../lib/app/content/auth_controller.dart | 6 ++ 7 files changed, 128 insertions(+), 43 deletions(-) diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 36410b2f..9869e54d 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -251,6 +251,9 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { if (previousError?.isTokenExpiredError ?? false) { _tokenManager.expireToken(); + // A guest's provider is static by necessity, not oversight: a second exchange would answer + // with another guest, under an id that is not `user`'s. So its session ends here, and the + // app starts a new one by disposing this client and building another. if (_tokenManager.usesStaticProvider) { throw ClientException(message: 'The token was refused and the provider has no other to give'); } @@ -308,6 +311,20 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { // Every exchange creates another guest, so one already established is kept. if (_tokenManager.userId != user.id) { + // No socket is opened while the exchange runs, so the connection state the guards in + // `connect` read still reads as idle. Joining the one in flight is what keeps a second + // caller arriving in that window from minting a guest of its own. + await (_guestExchange ??= _exchangeForGuestIdentity()); + } + + return _connectUser(connectWebSocket: connectWebSocket); + } + + // The exchange currently running, or `null` when none is. + Future? _guestExchange; + + Future _exchangeForGuestIdentity() async { + try { final result = await _guestRepository.createGuest(user); // Reported like every other connect failure, with the cause attached. @@ -328,9 +345,11 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { response.user.id, tokenProvider: tokenProvider, ); + } finally { + // Released either way: a success is held by the token manager's id from here on, and a + // failure has to leave the next `connect` free to try again. + _guestExchange = null; } - - return _connectUser(connectWebSocket: connectWebSocket); } Future _connectUser({ @@ -372,6 +391,10 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { _capabilitiesRepository.dispose(); + // Closes the connections Dio is holding open for reuse, which outlive the requests that + // opened them and so would outlive this client too. + httpClient.close(); + return super.dispose(); } diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index 2005bb48..ddde7499 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -253,9 +253,31 @@ abstract interface class StreamFeedsClient { /// Throws a [ClientException] if the connection fails, or if one is already established or in /// progress, and a [StateError] once [dispose] has been called. /// - /// Pass [connectWebSocket] as `false` for a client that only makes requests: no events arrive - /// and watching is rejected. An anonymous user always connects this way, having no token for a - /// socket. + /// ## Connecting without a WebSocket + /// + /// Pass [connectWebSocket] as `false` for a client that only makes requests. No socket is + /// opened, so [connectionState] stays [Initialized], nothing is emitted on [events] or + /// [connectionState], and a query with `watch: true` is rejected — watching is delivered over + /// the connection this skips. + /// + /// Requests themselves are unaffected: each one is signed as it is sent, from the + /// [TokenProvider] the client was given. So for a regular user this opens nothing and verifies + /// nothing — a token the server will refuse is not discovered here, but on the first request. + /// A guest still exchanges for its identity, since its id and token are what the requests need. + /// + /// An anonymous user always connects this way, having no token to authenticate a socket with, + /// and passing `true` does not change that. + /// + /// Calling [connect] again afterwards opens the socket, keeping the identity already + /// established. + /// + /// ## Guest sessions + /// + /// A guest's token is issued once, during the first [connect], and cannot be reissued: asking + /// for another would create another guest, under an id that is not [user]'s. So when the server + /// refuses a guest token as expired, the connection fails and stays down. Handle it by calling + /// [dispose] and building a new client, which starts a new guest session — and expect + /// `client.user.id` to differ, so anything holding the old id has to be refreshed with it. Future connect({ bool connectWebSocket = true, }); diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index d97630db..94432036 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -1453,6 +1453,40 @@ void main() { }, ); + feedsClientTest( + 'should exchange once when two connects overlap', + user: const User.guest('guest-123'), + connect: (tester) { + tester.mockApi( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest(user: UserRequest(id: 'guest-123')), + ), + result: CreateGuestResponse( + accessToken: generateTestUserToken('guest-123-xyz').rawValue, + duration: '10ms', + user: createDefaultUserResponse(id: 'guest-123-xyz', role: 'guest'), + ), + ); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + // No socket is opened while the exchange runs, so the connection state the second call + // reads still looks idle — and nothing else would stop it starting an exchange of its own. + await Future.wait([ + tester.client.connect(connectWebSocket: false), + tester.client.connect(connectWebSocket: false), + ]); + + // One guest, not one per caller: the second joined the exchange already running. + tester.verifyApi( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest(user: UserRequest(id: 'guest-123')), + ), + ); + expect(tester.client.user.id, 'guest-123-xyz'); + }, + ); + feedsClientTest( 'should connect a guest user on a retry after a failed createGuest call', user: const User.guest('guest-123'), diff --git a/packages/stream_feeds/test/state/activity_comment_list_test.dart b/packages/stream_feeds/test/state/activity_comment_list_test.dart index 261a5f40..6f161e46 100644 --- a/packages/stream_feeds/test/state/activity_comment_list_test.dart +++ b/packages/stream_feeds/test/state/activity_comment_list_test.dart @@ -199,7 +199,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-priority comment', + text: 'Top-level comment', userId: userId, ), ], @@ -345,7 +345,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-priority comment', + text: 'Top-level comment', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -388,7 +388,7 @@ void main() { final updatedTopLevelComment = tester.activityCommentListState.comments.first; expect(updatedTopLevelComment.replies, isEmpty); expect(updatedTopLevelComment.replyCount, 0); - // Top-priority comment should still exist + // Top-level comment should still exist expect(tester.activityCommentListState.comments, hasLength(1)); expect(tester.activityCommentListState.comments.first.id, commentId); }, @@ -404,7 +404,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-priority comment', + text: 'Top-level comment', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -458,7 +458,7 @@ void main() { final updatedSecondLevelComment = updatedTopLevelComment.replies!.first; expect(updatedSecondLevelComment.replies, isEmpty); expect(updatedSecondLevelComment.replyCount, 0); - // Second-priority comment should still exist + // Second-level comment should still exist expect(updatedTopLevelComment.replies, hasLength(1)); expect(updatedTopLevelComment.replies!.first.id, 'nested-reply-1'); }, @@ -791,7 +791,7 @@ void main() { id: commentId, objectId: activityId, objectType: 'activity', - text: 'Top-priority comment', + text: 'Top-level comment', userId: userId, replies: [ createDefaultThreadedCommentResponse( diff --git a/packages/stream_feeds/test/state/comment_reply_list_test.dart b/packages/stream_feeds/test/state/comment_reply_list_test.dart index 0eccea7d..54dd49d2 100644 --- a/packages/stream_feeds/test/state/comment_reply_list_test.dart +++ b/packages/stream_feeds/test/state/comment_reply_list_test.dart @@ -341,7 +341,7 @@ void main() { ); commentReplyListTest( - 'should skip top-priority comments (only handles replies)', + 'should skip top-level comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith(comments: const []), @@ -368,13 +368,13 @@ void main() { ), ); - // Verify state was not updated (only replies are added, not top-priority comments) + // Verify state was not updated (only replies are added, not top-level comments) expect(tester.commentReplyListState.replies, isEmpty); }, ); commentReplyListTest( - 'should skip top-priority comment updates (only handles replies)', + 'should skip top-level comment updates (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -414,7 +414,7 @@ void main() { ), ); - // Verify state was not updated (only replies are updated, not top-priority comments) + // Verify state was not updated (only replies are updated, not top-level comments) expect(tester.commentReplyListState.replies, hasLength(1)); expect( tester.commentReplyListState.replies.first.text, @@ -424,7 +424,7 @@ void main() { ); commentReplyListTest( - 'should skip top-priority comment deletions (only handles replies)', + 'should skip top-level comment deletions (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -459,7 +459,7 @@ void main() { ), ); - // Verify state was not updated (only replies are deleted, not top-priority comments) + // Verify state was not updated (only replies are deleted, not top-level comments) expect(tester.commentReplyListState.replies, hasLength(1)); }, ); @@ -474,14 +474,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, ), ], ), ), body: (tester) async { - // Initial state - has top-priority reply + // Initial state - has top-level reply expect(tester.commentReplyListState.replies, hasLength(1)); final initialReply = tester.commentReplyListState.replies.first; expect(initialReply.id, replyId); @@ -528,7 +528,7 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -587,7 +587,7 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -630,7 +630,7 @@ void main() { final updatedTopLevelReply = tester.commentReplyListState.replies.first; expect(updatedTopLevelReply.replies, isEmpty); expect(updatedTopLevelReply.replyCount, 0); - // Top-priority reply should still exist + // Top-level reply should still exist expect(tester.commentReplyListState.replies, hasLength(1)); expect(tester.commentReplyListState.replies.first.id, replyId); }, @@ -646,14 +646,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, ), ], ), ), body: (tester) async { - // Initial state - has top-priority reply + // Initial state - has top-level reply expect(tester.commentReplyListState.replies, hasLength(1)); final topLevelReply = tester.commentReplyListState.replies.first; expect(topLevelReply.replies, isNull); @@ -693,14 +693,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-priority reply', + text: 'Second-level reply', userId: userId, ), ], @@ -766,14 +766,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-priority reply', + text: 'Second-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -839,14 +839,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-priority reply', + text: 'Second-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -893,7 +893,7 @@ void main() { final updatedSecondLevelReply = updatedTopLevelReply.replies!.first; expect(updatedSecondLevelReply.replies, isEmpty); expect(updatedSecondLevelReply.replyCount, 0); - // Second-priority reply should still exist + // Second-level reply should still exist expect(updatedTopLevelReply.replies, hasLength(1)); expect(updatedTopLevelReply.replies!.first.id, 'nested-reply-1'); }, @@ -1088,7 +1088,7 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -1153,14 +1153,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-priority reply', + text: 'Second-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -1232,14 +1232,14 @@ void main() { id: replyId, objectId: commentId, objectType: 'activity', - text: 'Top-priority reply', + text: 'Top-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( id: 'nested-reply-1', objectId: commentId, objectType: 'activity', - text: 'Second-priority reply', + text: 'Second-level reply', userId: userId, replies: [ createDefaultThreadedCommentResponse( @@ -1301,7 +1301,7 @@ void main() { ); commentReplyListTest( - 'should skip reaction additions for top-priority comments (only handles replies)', + 'should skip reaction additions for top-level comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -1343,14 +1343,14 @@ void main() { ), ); - // Verify state was not updated (only replies get reactions, not top-priority comments) + // Verify state was not updated (only replies get reactions, not top-level comments) final updatedReply = tester.commentReplyListState.replies.first; expect(updatedReply.ownReactions, isEmpty); }, ); commentReplyListTest( - 'should skip reaction updates for top-priority comments (only handles replies)', + 'should skip reaction updates for top-level comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -1400,7 +1400,7 @@ void main() { ), ); - // Verify state was not updated (only replies get reactions updated, not top-priority comments) + // Verify state was not updated (only replies get reactions updated, not top-level comments) final updatedReply = tester.commentReplyListState.replies.first; expect(updatedReply.ownReactions, hasLength(1)); expect(updatedReply.ownReactions.first.type, reactionType); @@ -1408,7 +1408,7 @@ void main() { ); commentReplyListTest( - 'should skip reaction deletions for top-priority comments (only handles replies)', + 'should skip reaction deletions for top-level comments (only handles replies)', build: (client) => client.commentReplyList(query), setUp: (tester) => tester.get( modifyResponse: (response) => response.copyWith( @@ -1456,7 +1456,7 @@ void main() { ), ); - // Verify state was not updated (only replies get reactions deleted, not top-priority comments) + // Verify state was not updated (only replies get reactions deleted, not top-level comments) final updatedReply = tester.commentReplyListState.replies.first; expect(updatedReply.ownReactions, hasLength(1)); }, diff --git a/packages/stream_feeds_test/lib/src/testers/base_tester.dart b/packages/stream_feeds_test/lib/src/testers/base_tester.dart index 58041630..04860786 100644 --- a/packages/stream_feeds_test/lib/src/testers/base_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/base_tester.dart @@ -49,7 +49,7 @@ abstract base class BaseTester with ApiMockerMixin, CdnMockerMixin { /// The underlying StreamFeedsClient from which the subject was built. /// - /// Use this to access client-priority properties and methods. + /// Use this to access client-level properties and methods. /// /// Example: /// ```dart diff --git a/sample_app/lib/app/content/auth_controller.dart b/sample_app/lib/app/content/auth_controller.dart index 097309b5..376e9215 100644 --- a/sample_app/lib/app/content/auth_controller.dart +++ b/sample_app/lib/app/content/auth_controller.dart @@ -80,6 +80,12 @@ class AuthController extends ValueNotifier { error: error, stackTrace: stackTrace, ); + + // Nothing reaches this client again, and a connect that failed still left it holding a + // recovery handler and its subscriptions, so releasing it is what keeps a run of failed + // sign-in attempts from leaving one behind each time. + client.dispose().ignore(); + return const Unauthenticated(); }, ); From 65cde2592cd329646b45e97cb2d63754f2ce86cf Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 26 Aug 2026 11:55:03 +0200 Subject: [PATCH 30/31] refactor(llc): share the guest exchange through core's in-flight cache Core now owns the primitive this hand-rolled, so the nullable future and its `finally` give way to `InFlightCache`, keyed by the id the guest was requested under. `_exchangeForGuestIdentity` is left as just the exchange, with no slot bookkeeping. The adoption stays inside the deduped unit deliberately: moving it out would run it once per caller, and since `StaticTokenProvider` has no `==`, the second `setTokenProvider` would read as an identity switch and expire the token the first caller had just cached. Also rewrites the `connect` doc, which had grown to thirty lines of prose. It loses two implementation details a caller cannot observe -- that requests are signed as they are sent, and that a guest "exchanges" for its identity -- and gains a bulleted list, examples, and the plainer register the docs around it use. Verified against the backend that `watch: true` fails without a socket: both feeds controllers that accept it call `ValidateWatchConnectionID`, which answers 400. Core moves to f83b5d4 for the cache. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/client/feeds_client_impl.dart | 57 ++++++++----------- .../stream_feeds/lib/src/feeds_client.dart | 53 ++++++++++------- packages/stream_feeds/pubspec.yaml | 2 +- 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 9869e54d..b2a8d5a9 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -251,9 +251,8 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { if (previousError?.isTokenExpiredError ?? false) { _tokenManager.expireToken(); - // A guest's provider is static by necessity, not oversight: a second exchange would answer - // with another guest, under an id that is not `user`'s. So its session ends here, and the - // app starts a new one by disposing this client and building another. + // A guest cannot refresh: another exchange answers with a different guest. The session ends + // here, and the app starts another by building a new client. if (_tokenManager.usesStaticProvider) { throw ClientException(message: 'The token was refused and the provider has no other to give'); } @@ -311,45 +310,37 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { // Every exchange creates another guest, so one already established is kept. if (_tokenManager.userId != user.id) { - // No socket is opened while the exchange runs, so the connection state the guards in - // `connect` read still reads as idle. Joining the one in flight is what keeps a second - // caller arriving in that window from minting a guest of its own. - await (_guestExchange ??= _exchangeForGuestIdentity()); + // No socket is open yet, so the guards in `connect` do not stop a second caller arriving. + await _guestExchanges.run(user.id, _exchangeForGuestIdentity); } return _connectUser(connectWebSocket: connectWebSocket); } - // The exchange currently running, or `null` when none is. - Future? _guestExchange; + // Keyed by the id the guest was requested under, so callers overlapping on one exchange share it. + final _guestExchanges = InFlightCache(); Future _exchangeForGuestIdentity() async { - try { - final result = await _guestRepository.createGuest(user); - - // Reported like every other connect failure, with the cause attached. - final response = result.getOrElse( - (error, stackTrace) => throw ClientException( - message: 'Failed to create a guest user', - error: error, - stackTrace: stackTrace, - ), - ); + final result = await _guestRepository.createGuest(user); + + // Reported like every other connect failure, with the cause attached. + final response = result.getOrElse( + (error, stackTrace) => throw ClientException( + message: 'Failed to create a guest user', + error: error, + stackTrace: stackTrace, + ), + ); - final tokenProvider = TokenProvider.static(response.token); + final tokenProvider = TokenProvider.static(response.token); + _logger.d(() => 'guest created, server assigned ${response.user.id}'); - // The server assigns the id, so adopt it and authenticate as it. - _logger.d(() => 'guest created, server assigned ${response.user.id}'); - _user = response.user; - _tokenManager.setTokenProvider( - response.user.id, - tokenProvider: tokenProvider, - ); - } finally { - // Released either way: a success is held by the token manager's id from here on, and a - // failure has to leave the next `connect` free to try again. - _guestExchange = null; - } + // The server assigns the id, so adopt it and authenticate as it. + _user = response.user; + _tokenManager.setTokenProvider( + response.user.id, + tokenProvider: tokenProvider, + ); } Future _connectUser({ diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index ddde7499..c1bf34fc 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -248,36 +248,45 @@ abstract interface class StreamFeedsClient { /// Establishes a connection to the Stream service. /// - /// Call this before anything else on the client. + /// Call this before anything else on the client. Throws a [ClientException] if the connection + /// fails, or if one is already established or in progress, and a [StateError] after [dispose]. /// - /// Throws a [ClientException] if the connection fails, or if one is already established or in - /// progress, and a [StateError] once [dispose] has been called. + /// Pass [connectWebSocket] as `false` if the client only needs to make requests. In that case: /// - /// ## Connecting without a WebSocket + /// * no socket is opened, and [connectionState] stays [Initialized] + /// * nothing is emitted on [connectionState] or [events] + /// * anything asking for `watch: true` fails, because updates arrive over the socket /// - /// Pass [connectWebSocket] as `false` for a client that only makes requests. No socket is - /// opened, so [connectionState] stays [Initialized], nothing is emitted on [events] or - /// [connectionState], and a query with `watch: true` is rejected — watching is delivered over - /// the connection this skips. + /// Requests work either way. A token the server rejects is reported on the first request, not by + /// this call. Calling [connect] again opens the socket and keeps the same identity. /// - /// Requests themselves are unaffected: each one is signed as it is sent, from the - /// [TokenProvider] the client was given. So for a regular user this opens nothing and verifies - /// nothing — a token the server will refuse is not discovered here, but on the first request. - /// A guest still exchanges for its identity, since its id and token are what the requests need. + /// An anonymous user has no token for a socket, so it never opens one. /// - /// An anonymous user always connects this way, having no token to authenticate a socket with, - /// and passing `true` does not change that. + /// A guest gets its identity from the server on the first [connect], so [user] changes. That + /// identity cannot be renewed, because a new one would be a different guest. If the server + /// rejects a guest token, the connection stays down. Call [dispose], build a new client, and + /// expect a different [user] id. /// - /// Calling [connect] again afterwards opens the socket, keeping the identity already - /// established. + /// Example: + /// ```dart + /// // A client that only makes requests: no socket, and no watching. + /// await client.connect(connectWebSocket: false); + /// + /// // Opening the socket later keeps the identity already established. + /// await client.connect(); + /// ``` /// - /// ## Guest sessions + /// A guest needs no token provider, and reads back the id the server gave it: /// - /// A guest's token is issued once, during the first [connect], and cannot be reissued: asking - /// for another would create another guest, under an id that is not [user]'s. So when the server - /// refuses a guest token as expired, the connection fails and stays down. Handle it by calling - /// [dispose] and building a new client, which starts a new guest session — and expect - /// `client.user.id` to differ, so anything holding the old id has to be refreshed with it. + /// ```dart + /// final client = StreamFeedsClient( + /// apiKey: 'your-api-key', + /// user: const User.guest('guest-123'), + /// ); + /// + /// await client.connect(); + /// print(client.user.id); // assigned by the server, not 'guest-123' + /// ``` Future connect({ bool connectWebSocket = true, }); diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index c3ace292..8f7723bb 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 680e93a8fc1981293776dbdcd11a5f354d2d881b + ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb path: packages/stream_core uuid: ^4.5.1 From c50940f247e8e7a7d747208d2785b82eee04fd06 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 26 Aug 2026 13:41:23 +0200 Subject: [PATCH 31/31] fix(repo): sync melos' shared `stream_core` pin to f83b5d4 `stream_core` is declared under `command.bootstrap.dependencies`, which makes melos.yaml authoritative: bootstrap rewrites the package pubspecs to match it. The previous commit bumped only stream_feeds' pubspec to f83b5d4 for `InFlightCache` and left this pin at 680e93a8, so CI's bootstrap reverted the bump ("Updated 1 dependencies") and resolved a core without `InFlightCache` -- failing analyze, legacy analyze and build alike. The rewrite also dropped the `# ignore: invalid_dependency` comment above the git block, which is why analyze reported `invalid_dependency` as a second, fatal issue under `--fatal-infos`. With the refs matched, bootstrap leaves that pubspec alone and the ignore survives. Co-Authored-By: Claude Opus 5 (1M context) --- melos.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/melos.yaml b/melos.yaml index 510afb93..abe240de 100644 --- a/melos.yaml +++ b/melos.yaml @@ -53,7 +53,7 @@ command: stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 680e93a8fc1981293776dbdcd11a5f354d2d881b + ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb path: packages/stream_core video_player: ^2.10.0 uuid: ^4.5.1