diff --git a/docs/code_snippets/02_02_authentication.dart b/docs/code_snippets/02_02_authentication.dart new file mode 100644 index 00000000..eb75de38 --- /dev/null +++ b/docs/code_snippets/02_02_authentication.dart @@ -0,0 +1,95 @@ +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(); + + // 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 { + // 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(); + await client.dispose(); +} + +// Placeholder for your server token fetch +Future fetchTokenFromYourServer(String userId) async => ''; + +Future guestUserLogin() async { + // 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(); + + // 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(); + + await client.dispose(); +} + +Future anonymousUserLogin() async { + // 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(), + ); + + // 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(); + + await client.dispose(); +} + +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(); + + await client.dispose(); +} diff --git a/docs/code_snippets/12_01_logging.dart b/docs/code_snippets/12_01_logging.dart new file mode 100644 index 00000000..f5cec41d --- /dev/null +++ b/docs/code_snippets/12_01_logging.dart @@ -0,0 +1,101 @@ +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(); + + // 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 { + // 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(); + await client.dispose(); +} + +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(); + await client.dispose(); +} + +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(); + await client.dispose(); +} + +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(); + await client.dispose(); +} + +// 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/melos.yaml b/melos.yaml index c7f349ae..abe240de 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 @@ -47,7 +50,11 @@ command: shared_preferences: ^2.5.3 state_notifier: ^1.0.0 stream_feeds: ^0.5.1 - stream_core: ^0.4.0 + stream_core: + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb + 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/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 94cc0e12..6e730058 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,28 +1,43 @@ ## Upcoming -### New features -- Added `customHeaders` to `FeedsConfig` to send custom headers with every API request. - -### New fields -- Added `restrictReplies` (`ActivityRestrictReplies`) to `ActivityData` to expose the comment-reply restriction on an activity (everyone / nobody / people_i_follow). -- Added `restrictReplies` (`AddActivityRequestRestrictReplies?`) to `FeedAddActivityRequest` so comment restrictions can be set when creating an activity. -- Added `enrichmentOptions` (`EnrichmentOptions?`) to `FeedQuery` so optional server enrichment can be enabled per feed. Use `EnrichmentOptions(enrichOwnFollowings: true)` to receive `ownFollowings` on each activity's feed — required to determine whether the current user may comment when an activity's `restrictReplies` is `people_i_follow`. -- 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`. -- Added `skipEnrichUrl` to `FeedAddActivityRequest`, `ActivityAddCommentRequest`, and `ActivityUpdateCommentRequest` to skip URL enrichment. -- Added optional `deleteNotificationActivity` parameter to `Feed.deleteActivity`, `Feed.deleteComment`, `Feed.deleteActivityReaction`, `Feed.deleteCommentReaction`, `Activity.deleteComment`, and `Activity.deleteCommentReaction` — when `true`, the corresponding notification activity is also deleted. - -### 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 CHANGES + +- 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 call, recording, streaming and chat types that were never part of the Feeds API + +### ✨ Features + +- 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. 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 +- Added `bookmarkCount` and `editedAt` to `CommentData`, and `location` to `FeedData` +- Added `createNotificationActivity`, `skipPush` and `enrichOwnFields` flags to `FeedAddActivityRequest` +- Added `customHeaders` to `FeedsConfig`, sent with every API request. The SDK's own headers win where they overlap, and none of this reaches the WebSocket +- Added `skipEnrichUrl` to `FeedAddActivityRequest`, `ActivityAddCommentRequest` and `ActivityUpdateCommentRequest`, which leaves URLs in the text unenriched +- Added `restrictReplies` to `ActivityData` and `FeedAddActivityRequest`, saying who may comment on an activity: everyone, nobody, or people the author follows +- Added `enrichmentOptions` to `FeedQuery`. Pass `EnrichmentOptions(enrichOwnFollowings: true)` for `ownFollowings` on each activity, which is what tells you whether the current user may comment when `restrictReplies` is `people_i_follow` +- Added a `deleteNotificationActivity` flag to the `deleteActivity`, `deleteComment`, `deleteActivityReaction` and `deleteCommentReaction` methods on `Feed` and `Activity`, which deletes the matching notification activity too +- A restored activity or comment now reappears in feed and list state, through `ActivityRestoredEvent` and `CommentRestoredEvent` + +### 🐛 Bug Fixes + +- 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 +- Fixed the `X-Stream-Client` header: the SDK identifier was sent twice, the version was hardcoded, and the OS was left out + +### 🔄 Changed + +- `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, and `dart fix --apply` migrates them: | Old name | New name | |---|---| @@ -49,21 +64,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. - -### 🔄 Changed - -- Raised the minimum Dart SDK to `^3.12.0`. - -### 🐞 Fixed - -- Fixed the `X-Stream-Client` header values: the SDK identifier was duplicated, the version was hardcoded, and the OS was never reported. - ## 0.5.1 - Added missing state updates for the websocket events. - Add appeal-related methods to moderation client: `appeal`, `getAppeal`, and `queryAppeals`. 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 63de304c..198b95c0 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -24,6 +24,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'; @@ -63,10 +64,10 @@ import '../version.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; -class StreamFeedsClientImpl implements StreamFeedsClient { +class StreamFeedsClientImpl with Disposable implements StreamFeedsClient { StreamFeedsClientImpl({ required this.apiKey, - required this.user, + required User user, this.config = const FeedsConfig(), TokenProvider? tokenProvider, RetryStrategy? retryStrategy, @@ -75,24 +76,22 @@ class StreamFeedsClientImpl implements StreamFeedsClient { List? reconnectionPolicies, WebSocketProvider? wsProvider, api.DefaultApi? feedsRestApi, - }) { + }) : _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 || UserType.guest, _) => TokenProvider.static( - UserToken.anonymous(userId: user.id), - ), + 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())), }; _tokenManager = TokenManager( - userId: user.id, + userId: userId, tokenProvider: userTokenProvider, ); @@ -101,21 +100,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, @@ -151,10 +153,10 @@ class StreamFeedsClientImpl implements StreamFeedsClient { (client) => client.interceptors.addAll([ ApiKeyInterceptor(apiKey), HeadersInterceptor(_systemEnvironmentManager), - if (user.type != UserType.anonymous) connectionIdInterceptor, - AuthInterceptor(client, _tokenManager), + if (user.type != .anonymous) connectionIdInterceptor, + AuthInterceptor(client, _tokenManager, tag: 'SF:HttpAuth'), const ApiErrorInterceptor(), - LoggingInterceptor(requestHeader: true), + LoggingInterceptor(requestHeader: true, tag: 'SF:Http'), ]), ); @@ -177,6 +179,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { _moderationRepository = ModerationRepository(feedsApi); _pollsRepository = PollsRepository(feedsApi); _capabilitiesRepository = CapabilitiesRepository(feedsApi); + _guestRepository = GuestRepository(feedsApi); moderation = ModerationClient(_moderationRepository); @@ -192,10 +195,13 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final String apiKey; @override - final User user; + User get user => _user; + User _user; final FeedsConfig config; + final _logger = const StreamLogger('SF:Client'); + /// The underlying HTTP client, exposed so tests can assert on the request /// pipeline (interceptors, headers) without going through a mocked API. @visibleForTesting @@ -220,6 +226,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { late final ModerationRepository _moderationRepository; late final PollsRepository _pollsRepository; late final CapabilitiesRepository _capabilitiesRepository; + late final GuestRepository _guestRepository; static const _sdkName = 'stream-feeds'; static const _sdkIdentifier = 'dart'; @@ -241,21 +248,28 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override late final ModerationClient moderation; - Future _authenticateUser() async { - final userToken = await _tokenManager.getToken(); + Future _authenticateUser( + WsRequestSender send, + StreamApiError? previousError, + ) async { + if (previousError?.isTokenExpiredError ?? false) { + _tokenManager.expireToken(); + + // 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'); + } + } + 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, - ), + userDetails: .fromUser(user), ); - _ws.send(connectUserRequest); + return send(connectUserRequest).getOrThrow(); } @override @@ -264,18 +278,84 @@ 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) { + // 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); + } + + // Keyed by the id the guest was requested under, so callers overlapping on one exchange share it. + final _guestExchanges = InFlightCache(); + + Future _exchangeForGuestIdentity() async { + 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); + _logger.d(() => 'guest created, server assigned ${response.user.id}'); + + // The server assigns the id, so adopt it and authenticate as it. + _user = response.user; + _tokenManager.setTokenProvider( + response.user.id, + tokenProvider: tokenProvider, + ); + } + + 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([ @@ -283,19 +363,34 @@ 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(); + + // 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(); } @override diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index ce60f95d..e476e321 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,27 @@ export 'client/moderation_client.dart'; /// await client.connect(); /// ``` /// +/// ## Logging +/// +/// 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, +/// config: const FeedsConfig( +/// logConfig: StreamLogConfig(priority: StreamLogPriority.debug), +/// ), +/// ); +/// ``` +/// +/// 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. +/// /// ### Different User Types /// /// The [User] class supports different authentication types: @@ -87,9 +108,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 +125,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 +153,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 +171,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({ @@ -171,6 +187,10 @@ abstract interface class StreamFeedsClient { @visibleForTesting api.DefaultApi? feedsRestApi, }) = 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. @@ -236,25 +256,78 @@ 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] after [dispose]. + /// + /// Pass [connectWebSocket] as `false` if the client only needs to make requests. In that case: + /// + /// * 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 + /// + /// 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. + /// + /// An anonymous user has no token for a socket, so it never opens one. + /// + /// 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. /// /// Example: /// ```dart - /// try { - /// await client.connect(); - /// print('Connected successfully'); - /// } catch (e) { - /// print('Connection failed: $e'); - /// } + /// // 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(); /// ``` - Future connect(); + /// + /// A guest needs no token provider, and reads back the id the server gave 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, + }); /// Disconnects the current client. /// - /// Closes the WebSocket connection and cleans up all resources. + /// 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 + /// await client.disconnect(); + /// + /// // The same client, and the same subscriptions, can be reconnected later. + /// await client.connect(); + /// ``` + /// + /// 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 @@ -664,7 +737,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): @@ -686,7 +759,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'); @@ -812,7 +885,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): @@ -848,7 +921,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'); @@ -871,7 +944,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'); @@ -903,7 +976,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'); @@ -934,7 +1007,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'); @@ -957,7 +1030,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 848942e8..eadc841f 100644 --- a/packages/stream_feeds/lib/src/models/feeds_config.dart +++ b/packages/stream_feeds/lib/src/models/feeds_config.dart @@ -10,6 +10,7 @@ class FeedsConfig { const FeedsConfig({ this.cdnClient, this.pushNotificationsConfig, + this.logConfig, this.customHeaders, }); @@ -17,6 +18,12 @@ class FeedsConfig { 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; + /// Custom headers sent along with every API request. /// /// Useful for passing extra request context to the backend, for example 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)); + }); + } +} diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index c3522e5f..8f7723bb 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -30,7 +30,19 @@ dependencies: retrofit: ^4.9.2 rxdart: ^0.28.0 state_notifier: ^1.0.0 - stream_core: ^0.4.0 + stream_core: + # The ignore below silences `invalid_dependency` because we occasionally + # 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 + # 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: f83b5d4d706a79fc429de2d27aead4394b83c1fb + path: packages/stream_core uuid: ^4.5.1 dev_dependencies: 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 f1c877e4..94432036 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()); + }, + ); }); // ============================================================ @@ -935,4 +1310,217 @@ 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', + user: const User.guest('guest-123'), + connect: (tester) async { + tester.mockApi( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest( + user: UserRequest(id: 'guest-123'), + ), + ), + // The server may assign another id, so the mock differs from the request + result: CreateGuestResponse( + accessToken: generateTestUserToken('guest-123-xyz').rawValue, + duration: '10ms', + user: createDefaultUserResponse( + id: 'guest-123-xyz', + role: 'guest', + ), + ), + ); + tester.mockSuccessfulAuth('guest-123-xyz'); + await tester.client.connect(); + addTearDown(tester.client.dispose); + }, + body: (tester) { + expect( + tester.client.connectionState.value, + isA(), + ); + + // Verify the server-assigned identity is adopted + 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) { + tester.mockApiFailure( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest( + user: UserRequest(id: 'guest-123'), + ), + ), + error: Exception('Failed to create guest'), + ); + addTearDown(tester.client.dispose); + }, + body: (tester) async { + await expectLater( + tester.client.connect(), + throwsA( + isA() + .having((it) => it.message, 'message', 'Failed to create a guest user') + .having((it) => it.underlyingError, 'cause', isException), + ), + ); + + // 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 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'), + 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/lib/src/testers/base_tester.dart b/packages/stream_feeds_test/lib/src/testers/base_tester.dart index 0ab08e1e..04860786 100644 --- a/packages/stream_feeds_test/lib/src/testers/base_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/base_tester.dart @@ -113,6 +113,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 @@ -201,8 +216,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 @@ -240,6 +256,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, @@ -265,9 +282,7 @@ void testWithTester>( final client = StreamFeedsClient( apiKey: 'apiKey', user: user, - tokenProvider: TokenProvider.static( - generateTestUserToken(user.id), - ), + tokenProvider: tokenProvider ?? TokenProvider.static(generateTestUserToken(user.id)), feedsRestApi: feedsApi, wsProvider: (options) => webSocketChannel, config: FeedsConfig( @@ -302,7 +317,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 9f72c86d..8b637921 100644 --- a/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/websocket_tester.dart @@ -79,6 +79,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 @@ -87,30 +109,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(), @@ -169,9 +192,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( @@ -194,6 +218,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) { @@ -255,8 +284,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. 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..376e9215 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,28 @@ 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, + ); + + // 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(); + }, ); } @@ -70,7 +101,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..3e7d16fa 100644 --- a/sample_app/lib/notification/notification_background_handler.dart +++ b/sample_app/lib/notification/notification_background_handler.dart @@ -1,11 +1,13 @@ import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/foundation.dart' show kDebugMode; 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 +28,14 @@ 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: kDebugMode ? StreamLogPriority.debug : StreamLogPriority.none), + ); + _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) {