From 1584f3dd3de0d3406f0ca9671fcc4f86289af4fb Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 16 Oct 2025 22:09:22 +0700 Subject: [PATCH 01/19] squash progress --- lib/livekit_client.dart | 8 + lib/src/token_source/caching.dart | 174 ++++++++ lib/src/token_source/custom.dart | 38 ++ lib/src/token_source/endpoint.dart | 78 ++++ lib/src/token_source/jwt.dart | 68 +++ lib/src/token_source/literal.dart | 60 +++ lib/src/token_source/room_configuration.dart | 90 ++++ lib/src/token_source/sandbox.dart | 37 ++ lib/src/token_source/token_source.dart | 179 ++++++++ pubspec.lock | 32 ++ pubspec.yaml | 1 + test/token/caching_token_source_test.dart | 344 ++++++++++++++++ test/token/endpoint_token_source_test.dart | 411 +++++++++++++++++++ test/token/token_source_test.dart | 335 +++++++++++++++ 14 files changed, 1855 insertions(+) create mode 100644 lib/src/token_source/caching.dart create mode 100644 lib/src/token_source/custom.dart create mode 100644 lib/src/token_source/endpoint.dart create mode 100644 lib/src/token_source/jwt.dart create mode 100644 lib/src/token_source/literal.dart create mode 100644 lib/src/token_source/room_configuration.dart create mode 100644 lib/src/token_source/sandbox.dart create mode 100644 lib/src/token_source/token_source.dart create mode 100644 test/token/caching_token_source_test.dart create mode 100644 test/token/endpoint_token_source_test.dart create mode 100644 test/token/token_source_test.dart diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 026b1c39..d36df9e0 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -58,3 +58,11 @@ export 'src/types/video_encoding.dart'; export 'src/types/video_parameters.dart'; export 'src/widgets/screen_select_dialog.dart'; export 'src/widgets/video_track_renderer.dart'; +export 'src/token_source/token_source.dart'; +export 'src/token_source/room_configuration.dart'; +export 'src/token_source/literal.dart'; +export 'src/token_source/endpoint.dart'; +export 'src/token_source/custom.dart'; +export 'src/token_source/caching.dart'; +export 'src/token_source/sandbox.dart'; +export 'src/token_source/jwt.dart'; diff --git a/lib/src/token_source/caching.dart b/lib/src/token_source/caching.dart new file mode 100644 index 00000000..f98d0753 --- /dev/null +++ b/lib/src/token_source/caching.dart @@ -0,0 +1,174 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'jwt.dart'; +import 'token_source.dart'; + +/// A validator function that determines if cached credentials are still valid. +/// +/// The validator receives the original request options and cached response, and should +/// return `true` if the cached credentials are still valid for the given request. +/// +/// The default validator checks JWT expiration using [isResponseExpired]. +typedef TokenValidator = bool Function(TokenRequestOptions? options, TokenSourceResponse response); + +/// A tuple containing the request options and response that were cached. +class TokenStoreItem { + final TokenRequestOptions? options; + final TokenSourceResponse response; + + const TokenStoreItem({ + required this.options, + required this.response, + }); +} + +/// Protocol for storing and retrieving cached token credentials. +/// +/// Implement this abstract class to create custom storage solutions like +/// SharedPreferences or secure storage for token caching. +abstract class TokenStore { + /// Store credentials in the store. + /// + /// This replaces any existing cached credentials with the new ones. + Future store(TokenRequestOptions? options, TokenSourceResponse response); + + /// Retrieve the cached credentials. + /// + /// Returns the cached credentials if found, null otherwise. + Future retrieve(); + + /// Clear all stored credentials. + Future clear(); +} + +/// A simple in-memory store implementation for token caching. +/// +/// This store keeps credentials in memory and is lost when the app is terminated. +/// Suitable for development and testing. +class InMemoryTokenStore implements TokenStore { + TokenStoreItem? _cached; + + @override + Future store(TokenRequestOptions? options, TokenSourceResponse response) async { + _cached = TokenStoreItem(options: options, response: response); + } + + @override + Future retrieve() async { + return _cached; + } + + @override + Future clear() async { + _cached = null; + } +} + +/// Default validator that checks JWT expiration using [isResponseExpired]. +bool _defaultValidator(TokenRequestOptions? options, TokenSourceResponse response) { + return !isResponseExpired(response); +} + +/// A token source that caches credentials from any [TokenSourceConfigurable] using a configurable store. +/// +/// This wrapper improves performance by avoiding redundant token requests when credentials are still valid. +/// It automatically validates cached tokens and fetches new ones when needed. +/// +/// The cache will refetch credentials when: +/// - The cached token has expired (validated via [TokenValidator]) +/// - The request options have changed +/// - The cache has been explicitly invalidated via [invalidate] +class CachingTokenSource implements TokenSourceConfigurable { + final TokenSourceConfigurable _wrapped; + final TokenStore _store; + final TokenValidator _validator; + Completer? _fetchInProgress; + + /// Initialize a caching wrapper around any token source. + /// + /// - Parameters: + /// - wrapped: The underlying token source to wrap and cache + /// - store: The store implementation to use for caching (defaults to in-memory store) + /// - validator: A function to determine if cached credentials are still valid (defaults to JWT expiration check) + CachingTokenSource( + this._wrapped, { + TokenStore? store, + TokenValidator? validator, + }) : _store = store ?? InMemoryTokenStore(), + _validator = validator ?? _defaultValidator; + + @override + Future fetch([TokenRequestOptions? options]) async { + if (_fetchInProgress != null) { + return _fetchInProgress!.future; + } + + _fetchInProgress = Completer(); + + try { + // Check if we have a valid cached token + final cached = await _store.retrieve(); + if (cached != null && cached.options == options && _validator(cached.options, cached.response)) { + _fetchInProgress!.complete(cached.response); + return cached.response; + } + + final requestOptions = options ?? const TokenRequestOptions(); + final response = await _wrapped.fetch(requestOptions); + await _store.store(options, response); + _fetchInProgress!.complete(response); + return response; + } catch (e) { + _fetchInProgress!.completeError(e); + rethrow; + } finally { + _fetchInProgress = null; + } + } + + /// Invalidate the cached credentials, forcing a fresh fetch on the next request. + Future invalidate() async { + await _store.clear(); + } + + /// Get the cached credentials if one exists. + Future cachedResponse() async { + final cached = await _store.retrieve(); + return cached?.response; + } +} + +/// Extension to add caching capabilities to any [TokenSourceConfigurable]. +extension CachedTokenSource on TokenSourceConfigurable { + /// Wraps this token source with caching capabilities. + /// + /// The returned token source will reuse valid tokens and only fetch new ones when needed. + /// + /// - Parameters: + /// - store: The store implementation to use for caching (defaults to in-memory store) + /// - validator: A function to determine if cached credentials are still valid (defaults to JWT expiration check) + /// - Returns: A caching token source that wraps this token source + TokenSourceConfigurable cached({ + TokenStore? store, + TokenValidator? validator, + }) => + CachingTokenSource( + this, + store: store, + validator: validator, + ); +} diff --git a/lib/src/token_source/custom.dart b/lib/src/token_source/custom.dart new file mode 100644 index 00000000..ba4b7f35 --- /dev/null +++ b/lib/src/token_source/custom.dart @@ -0,0 +1,38 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'token_source.dart'; + +/// Function signature for custom token generation logic. +typedef CustomTokenFunction = Future Function(TokenRequestOptions options); + +/// A custom token source that executes provided logic to fetch credentials. +/// +/// This allows you to implement your own token fetching strategy with full control +/// over how credentials are generated or retrieved. +class CustomTokenSource implements TokenSourceConfigurable { + final CustomTokenFunction _function; + + /// Initialize with a custom token generation function. + /// + /// The [function] will be called whenever credentials need to be fetched, + /// receiving [TokenRequestOptions] and returning a [TokenSourceResponse]. + CustomTokenSource(CustomTokenFunction function) : _function = function; + + @override + Future fetch([TokenRequestOptions? options]) async { + final requestOptions = options ?? const TokenRequestOptions(); + return await _function(requestOptions); + } +} diff --git a/lib/src/token_source/endpoint.dart b/lib/src/token_source/endpoint.dart new file mode 100644 index 00000000..c3215a7c --- /dev/null +++ b/lib/src/token_source/endpoint.dart @@ -0,0 +1,78 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'token_source.dart'; + +/// A token source that fetches credentials via HTTP requests from a custom backend. +/// +/// This implementation: +/// - Sends a POST request to the specified URL (configurable via [method]) +/// - Encodes the request parameters as [TokenRequestOptions] JSON in the request body +/// - Includes any custom headers specified via [headers] +/// - Expects the response to be decoded as [TokenSourceResponse] JSON +/// - Validates HTTP status codes (200) and throws appropriate errors for failures +class EndpointTokenSource implements TokenSourceConfigurable { + /// The URL endpoint for token generation. + /// This should point to your backend service that generates LiveKit tokens. + final String url; + + /// The HTTP method to use for the token request (defaults to "POST"). + final String method; + + /// Additional HTTP headers to include with the request. + final Map headers; + + /// Optional HTTP client for testing purposes. + final http.Client? client; + + /// Initialize with endpoint configuration. + /// + /// - [url]: The URL endpoint for token generation + /// - [method]: The HTTP method (defaults to "POST") + /// - [headers]: Additional HTTP headers (optional) + /// - [client]: Custom HTTP client for testing (optional) + EndpointTokenSource({ + required this.url, + this.method = 'POST', + this.headers = const {}, + this.client, + }); + + @override + Future fetch([TokenRequestOptions? options]) async { + final requestOptions = options ?? const TokenRequestOptions(); + final requestBody = jsonEncode(requestOptions.toRequest().toJson()); + final uri = Uri.parse(url); + final requestHeaders = { + 'Content-Type': 'application/json', + ...headers, + }; + + final httpClient = client ?? http.Client(); + final response = method.toUpperCase() == 'GET' + ? await httpClient.get(uri, headers: requestHeaders) + : await httpClient.post(uri, headers: requestHeaders, body: requestBody); + + if (response.statusCode != 200) { + throw Exception('Error generating token from endpoint $url: received ${response.statusCode} / ${response.body}'); + } + + final responseBody = jsonDecode(response.body) as Map; + return TokenSourceResponse.fromJson(responseBody); + } +} diff --git a/lib/src/token_source/jwt.dart b/lib/src/token_source/jwt.dart new file mode 100644 index 00000000..b241c774 --- /dev/null +++ b/lib/src/token_source/jwt.dart @@ -0,0 +1,68 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +import 'token_source.dart'; + +/// Extension to extract LiveKit-specific claims from JWT tokens. +extension LiveKitClaims on JWT { + /// The display name for the participant. + String? get name => payload['name'] as String?; + + /// Custom metadata associated with the participant. + String? get metadata => payload['metadata'] as String?; + + /// Custom attributes for the participant. + Map? get attributes { + final attrs = payload['attributes']; + return attrs != null ? Map.from(attrs as Map) : null; + } +} + +/// Validates whether the JWT token in the response is expired or invalid. +/// +/// Returns `true` if the token is expired, invalid, or not yet valid (before nbf). +/// Returns `false` if the token is valid and can be used. +/// +/// This function checks: +/// - Token validity (can be decoded) +/// - Not-before time (nbf) - token is not yet valid +/// - Expiration time (exp) with 60 second tolerance +/// +/// A missing expiration field is treated as invalid. +bool isResponseExpired(TokenSourceResponse response) { + try { + final jwt = JWT.decode(response.participantToken); + final payload = jwt.payload as Map; + + final now = DateTime.timestamp(); + + // Check notBefore (nbf) - token not yet valid + final nbf = payload['nbf'] as int?; + if (nbf != null) { + final nbfTime = DateTime.fromMillisecondsSinceEpoch(nbf * 1000, isUtc: true); + if (now.isBefore(nbfTime)) return true; + } + + // Check expiration (exp) with 60 second tolerance + final exp = payload['exp'] as int?; + if (exp == null) return true; // Missing exp = invalid + final expiresAt = DateTime.fromMillisecondsSinceEpoch(exp * 1000 - 60000, isUtc: true); + + return now.isAfter(expiresAt); + } on JWTException { + return true; // Invalid token = expired + } +} diff --git a/lib/src/token_source/literal.dart b/lib/src/token_source/literal.dart new file mode 100644 index 00000000..61e8c04b --- /dev/null +++ b/lib/src/token_source/literal.dart @@ -0,0 +1,60 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'token_source.dart'; + +/// A token source that provides a fixed set of credentials without dynamic fetching. +/// +/// This is useful for testing, development, or when you have pre-generated tokens +/// that don't need to be refreshed dynamically. +/// +/// For dynamic token fetching, use [EndpointTokenSource] or implement [TokenSourceConfigurable]. +class LiteralTokenSource implements TokenSourceFixed { + /// The LiveKit server URL to connect to. + final String serverUrl; + + /// The JWT token for participant authentication. + final String participantToken; + + /// The display name for the participant (optional). + final String? participantName; + + /// The name of the room to join (optional). + final String? roomName; + + /// Initialize with fixed credentials. + /// + /// - Parameters: + /// - serverUrl: The LiveKit server URL to connect to + /// - participantToken: The JWT token for participant authentication + /// - participantName: The display name for the participant (optional) + /// - roomName: The name of the room to join (optional) + LiteralTokenSource({ + required this.serverUrl, + required this.participantToken, + this.participantName, + this.roomName, + }); + + /// Returns the fixed credentials without any network requests. + @override + Future fetch() async { + return TokenSourceResponse( + serverUrl: serverUrl, + participantToken: participantToken, + participantName: participantName, + roomName: roomName, + ); + } +} diff --git a/lib/src/token_source/room_configuration.dart b/lib/src/token_source/room_configuration.dart new file mode 100644 index 00000000..da019fcb --- /dev/null +++ b/lib/src/token_source/room_configuration.dart @@ -0,0 +1,90 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Configuration for dispatching an agent to a room. +class RoomAgentDispatch { + /// Name of the agent to dispatch. + final String? agentName; + + /// Metadata for the agent. + final String? metadata; + + const RoomAgentDispatch({ + this.agentName, + this.metadata, + }); + + Map toJson() => { + if (agentName != null) 'agent_name': agentName, + if (metadata != null) 'metadata': metadata, + }; +} + +/// Configuration for a LiveKit room. +/// +/// This class contains various settings that control room behavior such as timeouts, +/// participant limits, and agent dispatching. +class RoomConfiguration { + /// Room name, used as ID, must be unique. + final String? name; + + /// Number of seconds to keep the room open if no one joins. + final int? emptyTimeout; + + /// Number of seconds to keep the room open after everyone leaves. + final int? departureTimeout; + + /// Limit number of participants that can be in a room, excluding Egress and Ingress participants. + final int? maxParticipants; + + /// Metadata of room. + final String? metadata; + + /// Minimum playout delay of subscriber. + final int? minPlayoutDelay; + + /// Maximum playout delay of subscriber. + final int? maxPlayoutDelay; + + /// Improves A/V sync when playout delay set to a value larger than 200ms. + /// It will disable transceiver re-use so not recommended for rooms with frequent subscription changes. + final bool? syncStreams; + + /// Define agents that should be dispatched to this room. + final List? agents; + + const RoomConfiguration({ + this.name, + this.emptyTimeout, + this.departureTimeout, + this.maxParticipants, + this.metadata, + this.minPlayoutDelay, + this.maxPlayoutDelay, + this.syncStreams, + this.agents, + }); + + Map toJson() => { + if (name != null) 'name': name, + if (emptyTimeout != null) 'empty_timeout': emptyTimeout, + if (departureTimeout != null) 'departure_timeout': departureTimeout, + if (maxParticipants != null) 'max_participants': maxParticipants, + if (metadata != null) 'metadata': metadata, + if (minPlayoutDelay != null) 'min_playout_delay': minPlayoutDelay, + if (maxPlayoutDelay != null) 'max_playout_delay': maxPlayoutDelay, + if (syncStreams != null) 'sync_streams': syncStreams, + if (agents != null) 'agents': agents!.map((a) => a.toJson()).toList(), + }; +} diff --git a/lib/src/token_source/sandbox.dart b/lib/src/token_source/sandbox.dart new file mode 100644 index 00000000..9c15dfb0 --- /dev/null +++ b/lib/src/token_source/sandbox.dart @@ -0,0 +1,37 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'endpoint.dart'; + +/// A token source that queries LiveKit's sandbox token server for development and testing. +/// +/// This token source connects to LiveKit Cloud's sandbox environment, which is perfect for +/// quick prototyping and getting started with LiveKit development. +/// +/// **Warning:** This token source is **insecure** and should **never** be used in production. +/// +/// For production use, implement [EndpointTokenSource] with your own backend or use [CustomTokenSource]. +class SandboxTokenSource extends EndpointTokenSource { + /// Initialize with a sandbox ID from LiveKit Cloud. + /// + /// The [sandboxId] is obtained from your LiveKit Cloud project's sandbox settings. + SandboxTokenSource({ + required String sandboxId, + }) : super( + url: 'https://cloud-api.livekit.io/api/v2/sandbox/connection-details', + headers: { + 'X-Sandbox-ID': sandboxId, + }, + ); +} diff --git a/lib/src/token_source/token_source.dart b/lib/src/token_source/token_source.dart new file mode 100644 index 00000000..a9e6f9d9 --- /dev/null +++ b/lib/src/token_source/token_source.dart @@ -0,0 +1,179 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:collection/collection.dart'; + +import 'room_configuration.dart'; + +/// Request parameters for generating connection credentials. +class TokenRequestOptions { + /// The name of the room to connect to. Required for most token generation scenarios. + final String? roomName; + + /// The display name for the participant in the room. Optional but recommended for user experience. + final String? participantName; + + /// A unique identifier for the participant. Used for permissions and room management. + final String? participantIdentity; + + /// Custom metadata associated with the participant. Can be used for user profiles or additional context. + final String? participantMetadata; + + /// Custom attributes for the participant. Useful for storing key-value data like user roles or preferences. + final Map? participantAttributes; + + /// Name of the agent to dispatch. + final String? agentName; + + /// Metadata passed to the agent job. + final String? agentMetadata; + + const TokenRequestOptions({ + this.roomName, + this.participantName, + this.participantIdentity, + this.participantMetadata, + this.participantAttributes, + this.agentName, + this.agentMetadata, + }); + + /// Converts this options object to a wire-format request. + TokenSourceRequest toRequest() { + final List? agents = (agentName != null || agentMetadata != null) + ? [RoomAgentDispatch(agentName: agentName, metadata: agentMetadata)] + : null; + + return TokenSourceRequest( + roomName: roomName, + participantName: participantName, + participantIdentity: participantIdentity, + participantMetadata: participantMetadata, + participantAttributes: participantAttributes, + roomConfiguration: agents != null ? RoomConfiguration(agents: agents) : null, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! TokenRequestOptions) return false; + + return other.roomName == roomName && + other.participantName == participantName && + other.participantIdentity == participantIdentity && + other.participantMetadata == participantMetadata && + other.agentName == agentName && + other.agentMetadata == agentMetadata && + const MapEquality().equals(other.participantAttributes, participantAttributes); + } + + @override + int get hashCode { + return Object.hash( + roomName, + participantName, + participantIdentity, + participantMetadata, + agentName, + agentMetadata, + const MapEquality().hash(participantAttributes), + ); + } +} + +/// The JSON serializable format of the request sent to standard LiveKit token servers. +/// +/// This is an internal wire format class that separates the public API ([TokenRequestOptions]) +/// from the JSON structure sent over the network. +class TokenSourceRequest { + final String? roomName; + final String? participantName; + final String? participantIdentity; + final String? participantMetadata; + final Map? participantAttributes; + final RoomConfiguration? roomConfiguration; + + const TokenSourceRequest({ + this.roomName, + this.participantName, + this.participantIdentity, + this.participantMetadata, + this.participantAttributes, + this.roomConfiguration, + }); + + Map toJson() { + return { + if (roomName != null) 'room_name': roomName, + if (participantName != null) 'participant_name': participantName, + if (participantIdentity != null) 'participant_identity': participantIdentity, + if (participantMetadata != null) 'participant_metadata': participantMetadata, + if (participantAttributes != null) 'participant_attributes': participantAttributes, + if (roomConfiguration != null) 'room_config': roomConfiguration!.toJson(), + }; + } +} + +/// Response containing the credentials needed to connect to a LiveKit room. +class TokenSourceResponse { + /// The WebSocket URL for the LiveKit server. Use this to establish the connection. + final String serverUrl; + + /// The JWT token containing participant permissions and metadata. Required for authentication. + final String participantToken; + + /// The display name for the participant in the room. May be null if not specified. + final String? participantName; + + /// The name of the room the participant will join. May be null if not specified. + final String? roomName; + + const TokenSourceResponse({ + required this.serverUrl, + required this.participantToken, + this.participantName, + this.roomName, + }); + + factory TokenSourceResponse.fromJson(Map json) { + return TokenSourceResponse( + serverUrl: (json['server_url'] ?? json['serverUrl']) as String, + participantToken: (json['participant_token'] ?? json['participantToken']) as String, + participantName: (json['participant_name'] ?? json['participantName']) as String?, + roomName: (json['room_name'] ?? json['roomName']) as String?, + ); + } +} + +/// A token source that returns a fixed set of credentials without configurable options. +/// +/// This abstract class is designed for backwards compatibility with existing authentication infrastructure +/// that doesn't support dynamic room, participant, or agent parameter configuration. +abstract class TokenSourceFixed { + Future fetch(); +} + +/// A token source that provides configurable options for room, participant, and agent parameters. +/// +/// This abstract class allows dynamic configuration of connection parameters, making it suitable for +/// production applications that need flexible authentication and room management. +/// +/// Common implementations: +/// - [SandboxTokenSource]: For testing with LiveKit Cloud sandbox token server +/// - [EndpointTokenSource]: For custom backend endpoints using LiveKit's JSON format +/// - [CachingTokenSource]: For caching credentials (or use the `.cached()` extension method) +abstract class TokenSourceConfigurable { + Future fetch([TokenRequestOptions? options]); +} diff --git a/pubspec.lock b/pubspec.lock index 8567da2a..f3f48d5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "88.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" analyzer: dependency: transitive description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + dart_jsonwebtoken: + dependency: "direct main" + description: + name: dart_jsonwebtoken + sha256: "0de65691c1d736e9459f22f654ddd6fd8368a271d4e41aa07e53e6301eff5075" + url: "https://pub.dev" + source: hosted + version: "3.3.1" dart_style: dependency: transitive description: @@ -169,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" fake_async: dependency: transitive description: @@ -448,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" protobuf: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6090391e..01e04af0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: web: ^1.0.0 mime_type: ^1.0.1 path: ^1.9.1 + dart_jsonwebtoken: ^3.3.1 # Fix version to avoid version conflicts between WebRTC-SDK pods, which both this package and flutter_webrtc depend on. flutter_webrtc: 1.2.0 diff --git a/test/token/caching_token_source_test.dart b/test/token/caching_token_source_test.dart new file mode 100644 index 00000000..65d0242b --- /dev/null +++ b/test/token/caching_token_source_test.dart @@ -0,0 +1,344 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/token_source/caching.dart'; +import 'package:livekit_client/src/token_source/token_source.dart'; + +void main() { + group('CachingTokenSource', () { + test('caches valid token and reuses it', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // First fetch + final result1 = await cachingSource.fetch(); + expect(fetchCount, 1); + + // Second fetch should use cache + final result2 = await cachingSource.fetch(); + expect(fetchCount, 1); + expect(result2.participantToken, result1.participantToken); + }); + + test('refetches when token is expired', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = fetchCount == 1 ? _generateExpiredToken() : _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // First fetch with expired token + await cachingSource.fetch(); + expect(fetchCount, 1); + + // Second fetch should refetch due to expiration + await cachingSource.fetch(); + expect(fetchCount, 2); + }); + + test('refetches when options change', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // First fetch with initial options + await cachingSource.fetch(const TokenRequestOptions(roomName: 'room1')); + expect(fetchCount, 1); + + // Fetch with same options should use cache + await cachingSource.fetch(const TokenRequestOptions(roomName: 'room1')); + expect(fetchCount, 1); + + // Fetch with different options should refetch + await cachingSource.fetch(const TokenRequestOptions(roomName: 'room2')); + expect(fetchCount, 2); + }); + + test('refetches when participant metadata changes', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions(participantMetadata: 'meta1')); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions(participantMetadata: 'meta2')); + expect(fetchCount, 2); + }); + + test('refetches when participant attributes change', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions( + participantAttributes: {'key1': 'value1'}, + )); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions( + participantAttributes: {'key1': 'value2'}, + )); + expect(fetchCount, 2); + }); + + test('refetches when agentName changes', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions(agentName: 'agent1')); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions(agentName: 'agent2')); + expect(fetchCount, 2); + }); + + test('refetches when agentMetadata changes', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions(agentMetadata: 'meta1')); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions(agentMetadata: 'meta2')); + expect(fetchCount, 2); + }); + + test('handles concurrent fetches with single request', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + await Future.delayed(Duration(milliseconds: 100)); + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // Start multiple concurrent fetches + final futures = List.generate(5, (_) => cachingSource.fetch()); + final results = await Future.wait(futures); + + // Should only fetch once despite concurrent requests + expect(fetchCount, 1); + expect(results.every((r) => r.participantToken == results.first.participantToken), isTrue); + }); + + test('invalidate clears cache', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(); + expect(fetchCount, 1); + + // Invalidate cache + await cachingSource.invalidate(); + + // Should refetch after invalidation + await cachingSource.fetch(); + expect(fetchCount, 2); + }); + + test('cachedResponse returns current cached response', () async { + final mockSource = _MockTokenSource((options) async { + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + expect(await cachingSource.cachedResponse(), isNull); + + final response = await cachingSource.fetch(); + expect(await cachingSource.cachedResponse(), isNotNull); + expect((await cachingSource.cachedResponse())?.participantToken, response.participantToken); + + await cachingSource.invalidate(); + expect(await cachingSource.cachedResponse(), isNull); + }); + + test('custom validator is respected', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + // Custom validator that only caches when participantName is 'charlie' + bool customValidator(TokenRequestOptions? options, TokenSourceResponse response) { + return options?.participantName == 'charlie' && response.participantToken.isNotEmpty; + } + + final cachingSource = CachingTokenSource( + mockSource, + validator: customValidator, + ); + + // First fetch with matching validator + const charlieOptions = TokenRequestOptions( + roomName: 'test-room', + participantName: 'charlie', + ); + final result1 = await cachingSource.fetch(charlieOptions); + expect(fetchCount, 1); + + // Second fetch with same options should use cache (validator returns true) + final result2 = await cachingSource.fetch(charlieOptions); + expect(fetchCount, 1); + expect(result2.participantToken, result1.participantToken); + + // Fetch with different participantName should refetch (validator returns false) + const aliceOptions = TokenRequestOptions( + roomName: 'test-room', + participantName: 'alice', + ); + await cachingSource.fetch(aliceOptions); + expect(fetchCount, 2); + + // Fetch again with alice should refetch again (validator always returns false for alice) + await cachingSource.fetch(aliceOptions); + expect(fetchCount, 3); + }); + + test('cached extension creates CachingTokenSource', () { + final mockSource = _MockTokenSource((options) async { + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: _generateValidToken(), + ); + }); + + final cachedSource = mockSource.cached(); + expect(cachedSource, isA()); + }); + }); +} + +class _MockTokenSource implements TokenSourceConfigurable { + final Future Function(TokenRequestOptions options) _fetchFn; + + _MockTokenSource(this._fetchFn); + + @override + Future fetch([TokenRequestOptions? options]) { + return _fetchFn(options ?? const TokenRequestOptions()); + } +} + +String _generateValidToken() { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + + final payload = { + 'sub': 'test-participant', + 'video': {'room': 'test-room', 'roomJoin': true}, + 'exp': exp, + }; + + final jwt = JWT(payload); + return jwt.sign(SecretKey('test-secret')); +} + +String _generateExpiredToken() { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) - 3600; // -1 hour + + final payload = { + 'sub': 'test-participant', + 'video': {'room': 'test-room', 'roomJoin': true}, + 'exp': exp, + }; + + final jwt = JWT(payload); + return jwt.sign(SecretKey('test-secret')); +} diff --git a/test/token/endpoint_token_source_test.dart b/test/token/endpoint_token_source_test.dart new file mode 100644 index 00000000..ca1eb116 --- /dev/null +++ b/test/token/endpoint_token_source_test.dart @@ -0,0 +1,411 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:livekit_client/src/token_source/endpoint.dart'; +import 'package:livekit_client/src/token_source/room_configuration.dart'; +import 'package:livekit_client/src/token_source/token_source.dart'; + +void main() { + group('EndpointTokenSource HTTP Tests', () { + test('POST endpoint with agentName and agentMetadata in room_config.agents', () async { + http.Request? capturedRequest; + + final mockClient = MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'room_name': 'room-name', + 'participant_name': 'participant-name', + 'participant_token': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: 'https://example.com/token', + method: 'POST', + headers: {'hello': 'world'}, + client: mockClient, + ); + + const options = TokenRequestOptions( + roomName: 'room-name', + participantName: 'participant-name', + participantIdentity: 'participant-identity', + participantMetadata: 'participant-metadata', + agentName: 'agent-name', + agentMetadata: 'agent-metadata', + ); + + final response = await source.fetch(options); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + expect(response.participantName, 'participant-name'); + expect(response.roomName, 'room-name'); + + expect(capturedRequest, isNotNull); + expect(capturedRequest!.method, 'POST'); + expect(capturedRequest!.headers['hello'], 'world'); + expect(capturedRequest!.headers['Content-Type'], contains('application/json')); + + final requestBody = jsonDecode(capturedRequest!.body) as Map; + + expect(requestBody['room_name'], 'room-name'); + expect(requestBody['participant_name'], 'participant-name'); + expect(requestBody['participant_identity'], 'participant-identity'); + expect(requestBody['participant_metadata'], 'participant-metadata'); + + expect(requestBody['room_config'], isNotNull); + expect(requestBody['room_config']['agents'], isList); + + final agents = requestBody['room_config']['agents'] as List; + expect(agents.length, 1); + expect(agents[0]['agent_name'], 'agent-name'); + expect(agents[0]['metadata'], 'agent-metadata'); + }); + + test('GET endpoint', () async { + http.Request? capturedRequest; + + final mockClient = MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'participant_token': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: 'https://example.com/token', + method: 'GET', + client: mockClient, + ); + + final response = await source.fetch(); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + + expect(capturedRequest, isNotNull); + expect(capturedRequest!.method, 'GET'); + }); + + test('camelCase backward compatibility', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'serverUrl': 'wss://www.example.com', + 'roomName': 'room-name', + 'participantName': 'participant-name', + 'participantToken': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: 'https://example.com/token', + client: mockClient, + ); + + final response = await source.fetch(); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + expect(response.participantName, 'participant-name'); + expect(response.roomName, 'room-name'); + }); + + test('missing optional keys default to null', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'participant_token': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: 'https://example.com/token', + client: mockClient, + ); + + final response = await source.fetch(); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + expect(response.participantName, isNull); + expect(response.roomName, isNull); + }); + + test('error response throws exception', () async { + final mockClient = MockClient((request) async { + return http.Response('Not Found', 404); + }); + + final source = EndpointTokenSource( + url: 'https://example.com/token', + client: mockClient, + ); + + expect( + () => source.fetch(), + throwsA(isA().having( + (e) => e.toString(), + 'message', + contains('404'), + )), + ); + }); + + test('server error response throws exception', () async { + final mockClient = MockClient((request) async { + return http.Response('Internal Server Error', 500); + }); + + final source = EndpointTokenSource( + url: 'https://example.com/token', + client: mockClient, + ); + + expect( + () => source.fetch(), + throwsA(isA().having( + (e) => e.toString(), + 'message', + contains('500'), + )), + ); + }); + }); + + group('TokenRequestOptions Serialization', () { + test('toRequest().toJson() includes agentName and agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + agentName: 'test-agent', + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_name'], 'test-room'); + expect(json['participant_name'], 'test-participant'); + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect(json['room_config']['agents'], isList); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('toRequest().toJson() wraps only agentName in room_config.agents', () { + const options = TokenRequestOptions( + agentName: 'test-agent', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0].containsKey('metadata'), isFalse); + }); + + test('toRequest().toJson() wraps only agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0].containsKey('agent_name'), isFalse); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('includes participant attributes', () { + const options = TokenRequestOptions( + participantAttributes: { + 'key1': 'value1', + 'key2': 'value2', + }, + ); + + final json = options.toRequest().toJson(); + + expect(json['participant_attributes'], isMap); + expect(json['participant_attributes']['key1'], 'value1'); + expect(json['participant_attributes']['key2'], 'value2'); + }); + + test('handles empty options', () { + const options = TokenRequestOptions(); + + final json = options.toRequest().toJson(); + + expect(json, isEmpty); + }); + + test('only includes non-null fields', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: null, + participantIdentity: 'test-identity', + ); + + final json = options.toRequest().toJson(); + + expect(json.containsKey('room_name'), isTrue); + expect(json.containsKey('participant_name'), isFalse); + expect(json.containsKey('participant_identity'), isTrue); + }); + }); + + group('TokenSourceResponse', () { + test('fromJson parses all fields', () { + final json = { + 'server_url': 'https://test.livekit.io', + 'participant_token': 'test-token', + 'participant_name': 'test-participant', + 'room_name': 'test-room', + }; + + final response = TokenSourceResponse.fromJson(json); + + expect(response.serverUrl, 'https://test.livekit.io'); + expect(response.participantToken, 'test-token'); + expect(response.participantName, 'test-participant'); + expect(response.roomName, 'test-room'); + }); + + test('fromJson handles missing optional fields', () { + final json = { + 'server_url': 'https://test.livekit.io', + 'participant_token': 'test-token', + }; + + final response = TokenSourceResponse.fromJson(json); + + expect(response.serverUrl, 'https://test.livekit.io'); + expect(response.participantToken, 'test-token'); + expect(response.participantName, isNull); + expect(response.roomName, isNull); + }); + }); + + group('RoomConfiguration', () { + test('toJson includes all fields', () { + const config = RoomConfiguration( + name: 'test-room', + emptyTimeout: 300, + departureTimeout: 60, + maxParticipants: 10, + metadata: 'test-metadata', + minPlayoutDelay: 100, + maxPlayoutDelay: 500, + syncStreams: true, + agents: [ + RoomAgentDispatch( + agentName: 'test-agent', + metadata: '{"key":"value"}', + ), + ], + ); + + final json = config.toJson(); + + expect(json['name'], 'test-room'); + expect(json['empty_timeout'], 300); + expect(json['departure_timeout'], 60); + expect(json['max_participants'], 10); + expect(json['metadata'], 'test-metadata'); + expect(json['min_playout_delay'], 100); + expect(json['max_playout_delay'], 500); + expect(json['sync_streams'], true); + expect(json['agents'], isList); + expect((json['agents'] as List).length, 1); + }); + + test('toJson only includes non-null fields', () { + const config = RoomConfiguration( + name: 'test-room', + maxParticipants: 10, + ); + + final json = config.toJson(); + + expect(json.containsKey('name'), isTrue); + expect(json.containsKey('max_participants'), isTrue); + expect(json.containsKey('empty_timeout'), isFalse); + expect(json.containsKey('departure_timeout'), isFalse); + expect(json.containsKey('metadata'), isFalse); + expect(json.containsKey('min_playout_delay'), isFalse); + expect(json.containsKey('max_playout_delay'), isFalse); + expect(json.containsKey('sync_streams'), isFalse); + expect(json.containsKey('agents'), isFalse); + }); + }); + + group('RoomAgentDispatch', () { + test('toJson includes all fields', () { + const dispatch = RoomAgentDispatch( + agentName: 'test-agent', + metadata: '{"key":"value"}', + ); + + final json = dispatch.toJson(); + + expect(json['agent_name'], 'test-agent'); + expect(json['metadata'], '{"key":"value"}'); + }); + + test('toJson only includes non-null fields', () { + const dispatch = RoomAgentDispatch( + agentName: 'test-agent', + ); + + final json = dispatch.toJson(); + + expect(json.containsKey('agent_name'), isTrue); + expect(json.containsKey('metadata'), isFalse); + }); + + test('toJson handles both fields as null', () { + const dispatch = RoomAgentDispatch(); + + final json = dispatch.toJson(); + + expect(json.isEmpty, isTrue); + }); + }); +} diff --git a/test/token/token_source_test.dart b/test/token/token_source_test.dart new file mode 100644 index 00000000..20f65a97 --- /dev/null +++ b/test/token/token_source_test.dart @@ -0,0 +1,335 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/token_source/custom.dart'; +import 'package:livekit_client/src/token_source/jwt.dart'; +import 'package:livekit_client/src/token_source/literal.dart'; +import 'package:livekit_client/src/token_source/room_configuration.dart'; +import 'package:livekit_client/src/token_source/token_source.dart'; + +void main() { + group('JWT Validation', () { + test('valid token returns false for isResponseExpired', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isFalse); + }); + + test('expired token returns true for isResponseExpired', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) - 3600; // -1 hour + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('token within 60s tolerance returns true for isResponseExpired', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 30; // +30 seconds + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('token before nbf returns true for isResponseExpired', () { + final now = DateTime.timestamp(); + final nbf = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 7200; // +2 hours + + final token = _generateToken(nbf: nbf, exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('token without exp returns true for isResponseExpired', () { + final token = _generateToken(includeExp: false); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('invalid token returns true for isResponseExpired', () { + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: 'invalid.token.here', + ); + + expect(isResponseExpired(response), isTrue); + }); + }); + + group('LiteralTokenSource', () { + test('returns fixed response', () async { + final source = LiteralTokenSource( + serverUrl: 'https://test.livekit.io', + participantToken: 'test-token', + participantName: 'test-participant', + roomName: 'test-room', + ); + + final result = await source.fetch(); + + expect(result.serverUrl, 'https://test.livekit.io'); + expect(result.participantToken, 'test-token'); + expect(result.participantName, 'test-participant'); + expect(result.roomName, 'test-room'); + }); + + test('returns fixed response with minimal parameters', () async { + final source = LiteralTokenSource( + serverUrl: 'https://test.livekit.io', + participantToken: 'test-token', + ); + + final result = await source.fetch(); + + expect(result.serverUrl, 'https://test.livekit.io'); + expect(result.participantToken, 'test-token'); + expect(result.participantName, isNull); + expect(result.roomName, isNull); + }); + }); + + group('CustomTokenSource', () { + test('calls custom function with options', () async { + Future customFunction(TokenRequestOptions options) async { + return TokenSourceResponse( + serverUrl: 'https://custom.livekit.io', + participantToken: 'custom-token', + participantName: options.participantName, + roomName: options.roomName, + ); + } + + final source = CustomTokenSource(customFunction); + final result = await source.fetch(const TokenRequestOptions( + participantName: 'custom-participant', + roomName: 'custom-room', + )); + + expect(result.serverUrl, 'https://custom.livekit.io'); + expect(result.participantToken, 'custom-token'); + expect(result.participantName, 'custom-participant'); + expect(result.roomName, 'custom-room'); + }); + + test('handles null options', () async { + Future customFunction(TokenRequestOptions options) async { + return const TokenSourceResponse( + serverUrl: 'https://custom.livekit.io', + participantToken: 'custom-token', + ); + } + + final source = CustomTokenSource(customFunction); + final result = await source.fetch(); + + expect(result.serverUrl, 'https://custom.livekit.io'); + expect(result.participantToken, 'custom-token'); + }); + }); + + group('TokenRequestOptions', () { + test('toRequest().toJson() includes agentName and agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + agentName: 'test-agent', + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_name'], 'test-room'); + expect(json['participant_name'], 'test-participant'); + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect(json['room_config']['agents'], isList); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('toRequest().toJson() wraps only agentName in room_config.agents', () { + const options = TokenRequestOptions( + agentName: 'test-agent', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0].containsKey('metadata'), isFalse); + }); + + test('toRequest().toJson() wraps only agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0].containsKey('agent_name'), isFalse); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + }); + + group('TokenSourceRequest', () { + test('toRequest() wraps agentName and agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + agentName: 'test-agent', + agentMetadata: '{"key":"value"}', + ); + + final request = options.toRequest(); + + expect(request.roomName, 'test-room'); + expect(request.participantName, 'test-participant'); + expect(request.roomConfiguration, isNotNull); + expect(request.roomConfiguration!.agents, isNotNull); + expect(request.roomConfiguration!.agents!.length, 1); + expect(request.roomConfiguration!.agents![0].agentName, 'test-agent'); + expect(request.roomConfiguration!.agents![0].metadata, '{"key":"value"}'); + }); + + test('toRequest() creates null roomConfiguration when no agent fields', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + ); + + final request = options.toRequest(); + + expect(request.roomName, 'test-room'); + expect(request.participantName, 'test-participant'); + expect(request.roomConfiguration, isNull); + }); + + test('TokenSourceRequest.toJson() produces correct wire format', () { + final request = TokenSourceRequest( + roomName: 'test-room', + participantName: 'test-participant', + participantIdentity: 'test-identity', + participantMetadata: 'test-metadata', + participantAttributes: {'key1': 'value1'}, + roomConfiguration: const RoomConfiguration( + agents: [ + RoomAgentDispatch( + agentName: 'test-agent', + metadata: '{"key":"value"}', + ), + ], + ), + ); + + final json = request.toJson(); + + expect(json['room_name'], 'test-room'); + expect(json['participant_name'], 'test-participant'); + expect(json['participant_identity'], 'test-identity'); + expect(json['participant_metadata'], 'test-metadata'); + expect(json['participant_attributes'], {'key1': 'value1'}); + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('TokenSourceRequest.toJson() only includes non-null fields', () { + const request = TokenSourceRequest( + roomName: 'test-room', + ); + + final json = request.toJson(); + + expect(json.containsKey('room_name'), isTrue); + expect(json.containsKey('participant_name'), isFalse); + expect(json.containsKey('participant_identity'), isFalse); + expect(json.containsKey('participant_metadata'), isFalse); + expect(json.containsKey('participant_attributes'), isFalse); + expect(json.containsKey('room_config'), isFalse); + }); + + test('toRequest() preserves all fields', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + participantIdentity: 'test-identity', + participantMetadata: 'test-metadata', + participantAttributes: {'key1': 'value1', 'key2': 'value2'}, + ); + + final request = options.toRequest(); + + expect(request.roomName, 'test-room'); + expect(request.participantName, 'test-participant'); + expect(request.participantIdentity, 'test-identity'); + expect(request.participantMetadata, 'test-metadata'); + expect(request.participantAttributes, {'key1': 'value1', 'key2': 'value2'}); + }); + }); +} + +String _generateToken({int? nbf, int? exp, bool includeExp = true}) { + final payload = { + 'sub': 'test-participant', + 'video': {'room': 'test-room', 'roomJoin': true}, + }; + + if (nbf != null) { + payload['nbf'] = nbf; + } + + if (includeExp && exp != null) { + payload['exp'] = exp; + } + + final jwt = JWT(payload); + return jwt.sign(SecretKey('test-secret')); +} From 1938168dc1b9ebf687ef99aac2d9b6d3471fe156 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 22 Oct 2025 21:38:12 +0700 Subject: [PATCH 02/19] CompleterManager --- lib/src/support/completer_manager.dart | 114 ++++++ test/support/completer_manager_test.dart | 419 +++++++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 lib/src/support/completer_manager.dart create mode 100644 test/support/completer_manager_test.dart diff --git a/lib/src/support/completer_manager.dart b/lib/src/support/completer_manager.dart new file mode 100644 index 00000000..c56a0739 --- /dev/null +++ b/lib/src/support/completer_manager.dart @@ -0,0 +1,114 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +/// A manager for Completer instances that provides safe completion and automatic lifecycle management. +/// +/// Features: +/// - Safe completion (prevents double completion exceptions) +/// - Automatic timeout handling +/// - Clean state management and reusability +/// - Only exposes Future, not the Completer itself +/// - Thread-safe operations +class CompleterManager { + Completer _completer; + Timer? _timeoutTimer; + bool _isCompleted = false; + + /// Creates a new CompleterManager with an active completer. + CompleterManager() : _completer = Completer(); + + /// Gets the current future. Creates a new completer if previous one was completed. + Future get future { + if (_isCompleted) { + _reset(); + } + return _completer.future; + } + + /// Whether the current completer is completed. + bool get isCompleted => _isCompleted; + + /// Whether there's an active completer waiting for completion. + bool get isActive => !_isCompleted; + + /// Completes the current completer with the given value. + /// Returns true if successfully completed, false if already completed. + bool complete([FutureOr? value]) { + if (_isCompleted) { + return false; + } + + _isCompleted = true; + _timeoutTimer?.cancel(); + _timeoutTimer = null; + + _completer.complete(value); + return true; + } + + /// Completes the current completer with an error. + /// Returns true if successfully completed with error, false if already completed. + bool completeError(Object error, [StackTrace? stackTrace]) { + if (_isCompleted) { + return false; + } + + _isCompleted = true; + _timeoutTimer?.cancel(); + _timeoutTimer = null; + + _completer.completeError(error, stackTrace); + return true; + } + + /// Sets up a timeout for the current completer. + /// If the completer is not completed within the timeout, it will be completed with a TimeoutException. + void setTimer(Duration timeout, {String? timeoutReason}) { + if (_isCompleted) { + return; + } + + _timeoutTimer?.cancel(); + _timeoutTimer = Timer(timeout, () { + if (!_isCompleted) { + final reason = timeoutReason ?? 'Operation timed out after $timeout'; + completeError(TimeoutException(reason, timeout)); + } + }); + } + + /// Resets the manager, canceling any pending operations and preparing for reuse. + void reset() { + _reset(); + } + + void _reset() { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _isCompleted = false; + _completer = Completer(); + } + + /// Disposes the manager, canceling any pending operations. + void dispose() { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + if (!_isCompleted) { + _completer.completeError(StateError('CompleterManager disposed')); + _isCompleted = true; + } + } +} diff --git a/test/support/completer_manager_test.dart b/test/support/completer_manager_test.dart new file mode 100644 index 00000000..f157b8d7 --- /dev/null +++ b/test/support/completer_manager_test.dart @@ -0,0 +1,419 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/support/completer_manager.dart'; + +void main() { + group('CompleterManager', () { + late CompleterManager manager; + + setUp(() { + manager = CompleterManager(); + }); + + tearDown(() { + // Only dispose if not already completed or disposed + try { + if (manager.isActive) { + manager.complete('teardown'); + } + manager.dispose(); + } catch (_) { + // Already disposed, ignore + } + }); + + group('Basic Functionality', () { + test('should provide a future when accessed', () async { + final future = manager.future; + expect(future, isA>()); + expect(manager.isActive, isTrue); + expect(manager.isCompleted, isFalse); + + // Complete it to avoid tearDown issues + manager.complete('test'); + await expectLater(future, completion('test')); + }); + + test('should complete successfully with value', () async { + final future = manager.future; + final result = manager.complete('success'); + + expect(result, isTrue); + expect(manager.isCompleted, isTrue); + expect(manager.isActive, isFalse); + await expectLater(future, completion('success')); + }); + + test('should complete successfully without value', () async { + final manager = CompleterManager(); + final future = manager.future; + final result = manager.complete(); + + expect(result, isTrue); + expect(manager.isCompleted, isTrue); + await expectLater(future, completion(isNull)); + manager.dispose(); + }); + + test('should complete with error', () async { + final future = manager.future; + final testError = Exception('test error'); + final result = manager.completeError(testError); + + expect(result, isTrue); + expect(manager.isCompleted, isTrue); + expect(manager.isActive, isFalse); + await expectLater(future, throwsA(testError)); + }); + + test('should complete with error and stack trace', () async { + final future = manager.future; + final testError = Exception('test error'); + final stackTrace = StackTrace.current; + final result = manager.completeError(testError, stackTrace); + + expect(result, isTrue); + expect(manager.isCompleted, isTrue); + + try { + await future; + fail('Should have thrown an error'); + } catch (error, trace) { + expect(error, equals(testError)); + expect(trace, equals(stackTrace)); + } + }); + + test('should return false when completing already completed manager', () { + manager.complete('first'); + final result1 = manager.complete('second'); + final result2 = manager.completeError(Exception('error')); + + expect(result1, isFalse); + expect(result2, isFalse); + }); + }); + + group('State Properties', () { + test('initial state should be active and not completed', () { + expect(manager.isActive, isTrue); + expect(manager.isCompleted, isFalse); + }); + + test('should remain active after accessing future', () async { + final future = manager.future; + expect(manager.isActive, isTrue); + expect(manager.isCompleted, isFalse); + + // Complete it to avoid tearDown issues + manager.complete('test'); + await expectLater(future, completion('test')); + }); + + test('should be completed after completion', () async { + final future = manager.future; + manager.complete('done'); + + expect(manager.isActive, isFalse); + expect(manager.isCompleted, isTrue); + await expectLater(future, completion('done')); + }); + + test('should be completed after error completion', () async { + final future = manager.future; + final testError = Exception('error'); + manager.completeError(testError); + + expect(manager.isActive, isFalse); + expect(manager.isCompleted, isTrue); + await expectLater(future, throwsA(testError)); + }); + }); + + group('Reusability', () { + test('should create new future after previous completion', () async { + // First use + final future1 = manager.future; + manager.complete('first'); + await expectLater(future1, completion('first')); + + // Second use - should get new future + final future2 = manager.future; + expect(future2, isNot(same(future1))); + expect(manager.isActive, isTrue); + expect(manager.isCompleted, isFalse); + + manager.complete('second'); + await expectLater(future2, completion('second')); + }); + + test('should reset and be reusable', () async { + // First use + final future1 = manager.future; + manager.complete('first'); + await expectLater(future1, completion('first')); + + // Reset - note that reset creates a new completer, so it's not active until future is accessed + manager.reset(); + expect(manager.isCompleted, isFalse); + // After reset, manager is ready but not active until future is accessed + + // Second use after reset + final future2 = manager.future; + expect(manager.isActive, isTrue); + manager.complete('second'); + await expectLater(future2, completion('second')); + }); + + test('should reset even when active', () async { + final future1 = manager.future; + expect(manager.isActive, isTrue); + + manager.reset(); + expect(manager.isCompleted, isFalse); + // After reset, manager is ready but not active until future is accessed + + final future2 = manager.future; + expect(manager.isActive, isTrue); + expect(future2, isNot(same(future1))); + + // Complete it to avoid tearDown issues + manager.complete('test'); + await expectLater(future2, completion('test')); + }); + }); + + group('Timeout Functionality', () { + test('should timeout with default message', () async { + final future = manager.future; + manager.setTimer(Duration(milliseconds: 10)); + + await expectLater( + future, + throwsA(isA()), + ); + expect(manager.isCompleted, isTrue); + }); + + test('should timeout with custom message', () async { + final future = manager.future; + const customMessage = 'Custom timeout message'; + manager.setTimer(Duration(milliseconds: 10), timeoutReason: customMessage); + + try { + await future; + fail('Should have thrown TimeoutException'); + } catch (error) { + expect(error, isA()); + expect((error as TimeoutException).message, contains(customMessage)); + } + }); + + test('should cancel timeout on manual completion', () async { + final future = manager.future; + manager.setTimer(Duration(milliseconds: 100)); + + // Complete before timeout + manager.complete('completed'); + await expectLater(future, completion('completed')); + + // Wait longer than timeout to ensure it was cancelled + await Future.delayed(Duration(milliseconds: 150)); + // If we get here without additional errors, timeout was cancelled + }); + + test('should cancel timeout on error completion', () async { + final future = manager.future; + manager.setTimer(Duration(milliseconds: 100)); + + // Complete with error before timeout + final testError = Exception('test error'); + manager.completeError(testError); + await expectLater(future, throwsA(testError)); + + // Wait longer than timeout to ensure it was cancelled + await Future.delayed(Duration(milliseconds: 150)); + // If we get here without additional errors, timeout was cancelled + }); + + test('should replace previous timeout when setting new one', () async { + final future = manager.future; + manager.setTimer(Duration(milliseconds: 200)); + manager.setTimer(Duration(milliseconds: 10)); // This should replace the previous one + + await expectLater( + future, + throwsA(isA()), + ); + }); + + test('should not set timeout on completed manager', () async { + final future = manager.future; + manager.complete('done'); + await expectLater(future, completion('done')); + + // This should not throw or affect anything + manager.setTimer(Duration(milliseconds: 10)); + + // Verify still completed + expect(manager.isCompleted, isTrue); + }); + + test('should set timeout on active completer', () async { + // Manager is active by default now + expect(manager.isActive, isTrue); + + final future = manager.future; + manager.setTimer(Duration(milliseconds: 10)); + + // Should timeout + await expectLater(future, throwsA(isA())); + }); + }); + + group('Disposal', () { + test('should complete with error when disposed while active', () async { + final future = manager.future; + expect(manager.isActive, isTrue); + + manager.dispose(); + + await expectLater( + future, + throwsA(isA()), + ); + expect(manager.isCompleted, isTrue); + }); + + test('should not affect already completed manager', () async { + final future = manager.future; + manager.complete('done'); + await expectLater(future, completion('done')); + + // Dispose should not throw or change state + manager.dispose(); + expect(manager.isCompleted, isTrue); + }); + + test('should cancel timeout on dispose', () async { + final future = manager.future; + manager.setTimer(Duration(milliseconds: 10)); + + manager.dispose(); + + // Should complete with StateError, not TimeoutException + await expectLater( + future, + throwsA(isA()), + ); + }); + + test('should not allow operations after dispose', () async { + // Capture the future before disposing to handle the error + final future = manager.future; + manager.dispose(); + + // Dispose should complete with error + await expectLater(future, throwsA(isA())); + + // Further operations should return false + final result1 = manager.complete('test'); + final result2 = manager.completeError(Exception('error')); + + expect(result1, isFalse); + expect(result2, isFalse); + expect(manager.isCompleted, isTrue); + }); + }); + + group('Edge Cases', () { + test('should handle multiple future accesses for same completer', () async { + final future1 = manager.future; + final future2 = manager.future; + + expect(identical(future1, future2), isTrue); + expect(manager.isActive, isTrue); + + // Complete it to avoid tearDown issues + manager.complete('test'); + await expectLater(future1, completion('test')); + }); + + test('should handle rapid complete/reset cycles', () async { + for (int i = 0; i < 5; i++) { + final future = manager.future; + manager.complete('value_$i'); + await expectLater(future, completion('value_$i')); + if (i < 4) { + // Don't reset on the last iteration + manager.reset(); + } + } + }); + + test('should work with different generic types', () async { + final intManager = CompleterManager(); + final intFuture = intManager.future; + intManager.complete(42); + await expectLater(intFuture, completion(42)); + intManager.dispose(); + + final boolManager = CompleterManager(); + final boolFuture = boolManager.future; + boolManager.complete(true); + await expectLater(boolFuture, completion(isTrue)); + boolManager.dispose(); + }); + + test('should handle Future values in complete', () async { + final future = manager.future; + final futureValue = Future.value('async_result'); + manager.complete(futureValue); + + await expectLater(future, completion('async_result')); + }); + }); + + group('Thread Safety', () { + test('should handle concurrent operations safely', () async { + final futures = []; + + // Start multiple concurrent operations + for (int i = 0; i < 10; i++) { + futures.add(Future(() async { + final future = manager.future; + if (i == 0) { + // Only the first one should succeed in completing + await Future.delayed(Duration(milliseconds: 1)); + manager.complete('winner'); + } + return future; + })); + } + + final results = await Future.wait(futures, eagerError: false); + + // All should complete with the same value + for (final result in results) { + expect(result, equals('winner')); + } + }); + }); + }); +} From 6e53e5ad3d92cfe4735d321642c761e6e8179fb3 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:48:34 +0700 Subject: [PATCH 03/19] minor adjustments --- lib/src/token_source/caching.dart | 42 +++++++++++----------- lib/src/token_source/endpoint.dart | 26 +++++++++----- lib/src/token_source/token_source.dart | 2 +- test/token/caching_token_source_test.dart | 39 +++++++++++++++----- test/token/endpoint_token_source_test.dart | 36 +++++++++++++++---- test/token/token_source_test.dart | 2 +- 6 files changed, 103 insertions(+), 44 deletions(-) diff --git a/lib/src/token_source/caching.dart b/lib/src/token_source/caching.dart index f98d0753..49903c61 100644 --- a/lib/src/token_source/caching.dart +++ b/lib/src/token_source/caching.dart @@ -14,6 +14,7 @@ import 'dart:async'; +import '../support/completer_manager.dart'; import 'jwt.dart'; import 'token_source.dart'; @@ -23,11 +24,11 @@ import 'token_source.dart'; /// return `true` if the cached credentials are still valid for the given request. /// /// The default validator checks JWT expiration using [isResponseExpired]. -typedef TokenValidator = bool Function(TokenRequestOptions? options, TokenSourceResponse response); +typedef TokenValidator = bool Function(TokenRequestOptions options, TokenSourceResponse response); /// A tuple containing the request options and response that were cached. class TokenStoreItem { - final TokenRequestOptions? options; + final TokenRequestOptions options; final TokenSourceResponse response; const TokenStoreItem({ @@ -44,7 +45,7 @@ abstract class TokenStore { /// Store credentials in the store. /// /// This replaces any existing cached credentials with the new ones. - Future store(TokenRequestOptions? options, TokenSourceResponse response); + Future store(TokenRequestOptions options, TokenSourceResponse response); /// Retrieve the cached credentials. /// @@ -63,7 +64,7 @@ class InMemoryTokenStore implements TokenStore { TokenStoreItem? _cached; @override - Future store(TokenRequestOptions? options, TokenSourceResponse response) async { + Future store(TokenRequestOptions options, TokenSourceResponse response) async { _cached = TokenStoreItem(options: options, response: response); } @@ -79,7 +80,7 @@ class InMemoryTokenStore implements TokenStore { } /// Default validator that checks JWT expiration using [isResponseExpired]. -bool _defaultValidator(TokenRequestOptions? options, TokenSourceResponse response) { +bool _defaultValidator(TokenRequestOptions options, TokenSourceResponse response) { return !isResponseExpired(response); } @@ -96,7 +97,7 @@ class CachingTokenSource implements TokenSourceConfigurable { final TokenSourceConfigurable _wrapped; final TokenStore _store; final TokenValidator _validator; - Completer? _fetchInProgress; + final Map> _inflightRequests = {}; /// Initialize a caching wrapper around any token source. /// @@ -112,31 +113,32 @@ class CachingTokenSource implements TokenSourceConfigurable { _validator = validator ?? _defaultValidator; @override - Future fetch([TokenRequestOptions? options]) async { - if (_fetchInProgress != null) { - return _fetchInProgress!.future; + Future fetch(TokenRequestOptions options) async { + final existingManager = _inflightRequests[options]; + if (existingManager != null && existingManager.isActive) { + return existingManager.future; } - _fetchInProgress = Completer(); + final manager = existingManager ?? CompleterManager(); + _inflightRequests[options] = manager; + final resultFuture = manager.future; try { - // Check if we have a valid cached token final cached = await _store.retrieve(); if (cached != null && cached.options == options && _validator(cached.options, cached.response)) { - _fetchInProgress!.complete(cached.response); - return cached.response; + manager.complete(cached.response); + return resultFuture; } - final requestOptions = options ?? const TokenRequestOptions(); - final response = await _wrapped.fetch(requestOptions); + final response = await _wrapped.fetch(options); await _store.store(options, response); - _fetchInProgress!.complete(response); - return response; - } catch (e) { - _fetchInProgress!.completeError(e); + manager.complete(response); + return resultFuture; + } catch (e, stackTrace) { + manager.completeError(e, stackTrace); rethrow; } finally { - _fetchInProgress = null; + _inflightRequests.remove(options); } } diff --git a/lib/src/token_source/endpoint.dart b/lib/src/token_source/endpoint.dart index c3215a7c..782cdc79 100644 --- a/lib/src/token_source/endpoint.dart +++ b/lib/src/token_source/endpoint.dart @@ -25,7 +25,7 @@ import 'token_source.dart'; /// - Encodes the request parameters as [TokenRequestOptions] JSON in the request body /// - Includes any custom headers specified via [headers] /// - Expects the response to be decoded as [TokenSourceResponse] JSON -/// - Validates HTTP status codes (200) and throws appropriate errors for failures +/// - Validates HTTP status codes (200-299) and throws appropriate errors for failures class EndpointTokenSource implements TokenSourceConfigurable { /// The URL endpoint for token generation. /// This should point to your backend service that generates LiveKit tokens. @@ -54,9 +54,8 @@ class EndpointTokenSource implements TokenSourceConfigurable { }); @override - Future fetch([TokenRequestOptions? options]) async { - final requestOptions = options ?? const TokenRequestOptions(); - final requestBody = jsonEncode(requestOptions.toRequest().toJson()); + Future fetch(TokenRequestOptions options) async { + final requestBody = jsonEncode(options.toRequest().toJson()); final uri = Uri.parse(url); final requestHeaders = { 'Content-Type': 'application/json', @@ -64,11 +63,22 @@ class EndpointTokenSource implements TokenSourceConfigurable { }; final httpClient = client ?? http.Client(); - final response = method.toUpperCase() == 'GET' - ? await httpClient.get(uri, headers: requestHeaders) - : await httpClient.post(uri, headers: requestHeaders, body: requestBody); + final shouldCloseClient = client == null; + late final http.Response response; - if (response.statusCode != 200) { + try { + final request = http.Request(method, uri); + request.headers.addAll(requestHeaders); + request.body = requestBody; + final streamedResponse = await httpClient.send(request); + response = await http.Response.fromStream(streamedResponse); + } finally { + if (shouldCloseClient) { + httpClient.close(); + } + } + + if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Error generating token from endpoint $url: received ${response.statusCode} / ${response.body}'); } diff --git a/lib/src/token_source/token_source.dart b/lib/src/token_source/token_source.dart index a9e6f9d9..0bfa2439 100644 --- a/lib/src/token_source/token_source.dart +++ b/lib/src/token_source/token_source.dart @@ -175,5 +175,5 @@ abstract class TokenSourceFixed { /// - [EndpointTokenSource]: For custom backend endpoints using LiveKit's JSON format /// - [CachingTokenSource]: For caching credentials (or use the `.cached()` extension method) abstract class TokenSourceConfigurable { - Future fetch([TokenRequestOptions? options]); + Future fetch(TokenRequestOptions options); } diff --git a/test/token/caching_token_source_test.dart b/test/token/caching_token_source_test.dart index 65d0242b..eed9ee7a 100644 --- a/test/token/caching_token_source_test.dart +++ b/test/token/caching_token_source_test.dart @@ -34,11 +34,11 @@ void main() { final cachingSource = CachingTokenSource(mockSource); // First fetch - final result1 = await cachingSource.fetch(); + final result1 = await cachingSource.fetch(const TokenRequestOptions()); expect(fetchCount, 1); // Second fetch should use cache - final result2 = await cachingSource.fetch(); + final result2 = await cachingSource.fetch(const TokenRequestOptions()); expect(fetchCount, 1); expect(result2.participantToken, result1.participantToken); }); @@ -57,11 +57,11 @@ void main() { final cachingSource = CachingTokenSource(mockSource); // First fetch with expired token - await cachingSource.fetch(); + await cachingSource.fetch(const TokenRequestOptions()); expect(fetchCount, 1); // Second fetch should refetch due to expiration - await cachingSource.fetch(); + await cachingSource.fetch(const TokenRequestOptions()); expect(fetchCount, 2); }); @@ -190,7 +190,7 @@ void main() { final cachingSource = CachingTokenSource(mockSource); // Start multiple concurrent fetches - final futures = List.generate(5, (_) => cachingSource.fetch()); + final futures = List.generate(5, (_) => cachingSource.fetch(const TokenRequestOptions())); final results = await Future.wait(futures); // Should only fetch once despite concurrent requests @@ -198,6 +198,29 @@ void main() { expect(results.every((r) => r.participantToken == results.first.participantToken), isTrue); }); + test('concurrent fetches with different options fetch independently', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + await Future.delayed(Duration(milliseconds: 50)); + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: '$token-${options.roomName}', + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + final futureOne = cachingSource.fetch(const TokenRequestOptions(roomName: 'room-a')); + final futureTwo = cachingSource.fetch(const TokenRequestOptions(roomName: 'room-b')); + + final responses = await Future.wait([futureOne, futureTwo]); + + expect(fetchCount, 2); + expect(responses[0].participantToken == responses[1].participantToken, isFalse); + }); + test('invalidate clears cache', () async { var fetchCount = 0; final mockSource = _MockTokenSource((options) async { @@ -211,14 +234,14 @@ void main() { final cachingSource = CachingTokenSource(mockSource); - await cachingSource.fetch(); + await cachingSource.fetch(const TokenRequestOptions()); expect(fetchCount, 1); // Invalidate cache await cachingSource.invalidate(); // Should refetch after invalidation - await cachingSource.fetch(); + await cachingSource.fetch(const TokenRequestOptions()); expect(fetchCount, 2); }); @@ -235,7 +258,7 @@ void main() { expect(await cachingSource.cachedResponse(), isNull); - final response = await cachingSource.fetch(); + final response = await cachingSource.fetch(const TokenRequestOptions()); expect(await cachingSource.cachedResponse(), isNotNull); expect((await cachingSource.cachedResponse())?.participantToken, response.participantToken); diff --git a/test/token/endpoint_token_source_test.dart b/test/token/endpoint_token_source_test.dart index ca1eb116..a42f9afd 100644 --- a/test/token/endpoint_token_source_test.dart +++ b/test/token/endpoint_token_source_test.dart @@ -84,7 +84,7 @@ void main() { expect(agents[0]['metadata'], 'agent-metadata'); }); - test('GET endpoint', () async { + test('GET endpoint with body', () async { http.Request? capturedRequest; final mockClient = MockClient((request) async { @@ -104,13 +104,37 @@ void main() { client: mockClient, ); - final response = await source.fetch(); + final response = await source.fetch(const TokenRequestOptions()); expect(response.serverUrl, 'wss://www.example.com'); expect(response.participantToken, 'token'); expect(capturedRequest, isNotNull); expect(capturedRequest!.method, 'GET'); + // Body is always sent even for GET requests + expect(capturedRequest!.body, '{}'); + }); + + test('accepts non-200 success responses', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'participant_token': 'token', + }), + 201, + ); + }); + + final source = EndpointTokenSource( + url: 'https://example.com/token', + client: mockClient, + ); + + final response = await source.fetch(const TokenRequestOptions()); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); }); test('camelCase backward compatibility', () async { @@ -131,7 +155,7 @@ void main() { client: mockClient, ); - final response = await source.fetch(); + final response = await source.fetch(const TokenRequestOptions()); expect(response.serverUrl, 'wss://www.example.com'); expect(response.participantToken, 'token'); @@ -155,7 +179,7 @@ void main() { client: mockClient, ); - final response = await source.fetch(); + final response = await source.fetch(const TokenRequestOptions()); expect(response.serverUrl, 'wss://www.example.com'); expect(response.participantToken, 'token'); @@ -174,7 +198,7 @@ void main() { ); expect( - () => source.fetch(), + () => source.fetch(const TokenRequestOptions()), throwsA(isA().having( (e) => e.toString(), 'message', @@ -194,7 +218,7 @@ void main() { ); expect( - () => source.fetch(), + () => source.fetch(const TokenRequestOptions()), throwsA(isA().having( (e) => e.toString(), 'message', diff --git a/test/token/token_source_test.dart b/test/token/token_source_test.dart index 20f65a97..c72b54d8 100644 --- a/test/token/token_source_test.dart +++ b/test/token/token_source_test.dart @@ -160,7 +160,7 @@ void main() { } final source = CustomTokenSource(customFunction); - final result = await source.fetch(); + final result = await source.fetch(const TokenRequestOptions()); expect(result.serverUrl, 'https://custom.livekit.io'); expect(result.participantToken, 'custom-token'); From c4f1863d61ba9605861ae40caf66a87838e3edcc Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:52:33 +0700 Subject: [PATCH 04/19] Update pubspec.lock --- pubspec.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index f3f48d5c..b38eb7b1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "88.0.0" + version: "91.0.0" adaptive_number: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "8.4.1" args: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: build - sha256: "5b887c55a0f734b433b3b2d89f9cd1f99eb636b17e268a5b4259258bc916504b" + sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" built_collection: dependency: transitive description: @@ -324,10 +324,10 @@ packages: dependency: transitive description: name: logger - sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c" + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "2.6.2" logging: dependency: "direct main" description: @@ -412,18 +412,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 url: "https://pub.dev" source: hosted - version: "2.2.18" + version: "2.2.20" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" path_provider_linux: dependency: transitive description: @@ -513,10 +513,10 @@ packages: dependency: transitive description: name: source_gen - sha256: ccf30b0c9fbcd79d8b6f5bfac23199fb354938436f62475e14aea0f29ee0f800 + sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" source_span: dependency: transitive description: @@ -625,10 +625,10 @@ packages: dependency: transitive description: name: watcher - sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.4" web: dependency: "direct main" description: @@ -649,10 +649,10 @@ packages: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.15.0" win32_registry: dependency: transitive description: @@ -686,5 +686,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" From 80529ac6c6dee6105bb511b170b47e38c70a9cfc Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:59:00 +0700 Subject: [PATCH 05/19] replace completer manager --- lib/src/support/completer_manager.dart | 114 ------ lib/src/token_source/caching.dart | 22 +- test/support/completer_manager_test.dart | 419 ----------------------- 3 files changed, 11 insertions(+), 544 deletions(-) delete mode 100644 lib/src/support/completer_manager.dart delete mode 100644 test/support/completer_manager_test.dart diff --git a/lib/src/support/completer_manager.dart b/lib/src/support/completer_manager.dart deleted file mode 100644 index c56a0739..00000000 --- a/lib/src/support/completer_manager.dart +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2025 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:async'; - -/// A manager for Completer instances that provides safe completion and automatic lifecycle management. -/// -/// Features: -/// - Safe completion (prevents double completion exceptions) -/// - Automatic timeout handling -/// - Clean state management and reusability -/// - Only exposes Future, not the Completer itself -/// - Thread-safe operations -class CompleterManager { - Completer _completer; - Timer? _timeoutTimer; - bool _isCompleted = false; - - /// Creates a new CompleterManager with an active completer. - CompleterManager() : _completer = Completer(); - - /// Gets the current future. Creates a new completer if previous one was completed. - Future get future { - if (_isCompleted) { - _reset(); - } - return _completer.future; - } - - /// Whether the current completer is completed. - bool get isCompleted => _isCompleted; - - /// Whether there's an active completer waiting for completion. - bool get isActive => !_isCompleted; - - /// Completes the current completer with the given value. - /// Returns true if successfully completed, false if already completed. - bool complete([FutureOr? value]) { - if (_isCompleted) { - return false; - } - - _isCompleted = true; - _timeoutTimer?.cancel(); - _timeoutTimer = null; - - _completer.complete(value); - return true; - } - - /// Completes the current completer with an error. - /// Returns true if successfully completed with error, false if already completed. - bool completeError(Object error, [StackTrace? stackTrace]) { - if (_isCompleted) { - return false; - } - - _isCompleted = true; - _timeoutTimer?.cancel(); - _timeoutTimer = null; - - _completer.completeError(error, stackTrace); - return true; - } - - /// Sets up a timeout for the current completer. - /// If the completer is not completed within the timeout, it will be completed with a TimeoutException. - void setTimer(Duration timeout, {String? timeoutReason}) { - if (_isCompleted) { - return; - } - - _timeoutTimer?.cancel(); - _timeoutTimer = Timer(timeout, () { - if (!_isCompleted) { - final reason = timeoutReason ?? 'Operation timed out after $timeout'; - completeError(TimeoutException(reason, timeout)); - } - }); - } - - /// Resets the manager, canceling any pending operations and preparing for reuse. - void reset() { - _reset(); - } - - void _reset() { - _timeoutTimer?.cancel(); - _timeoutTimer = null; - _isCompleted = false; - _completer = Completer(); - } - - /// Disposes the manager, canceling any pending operations. - void dispose() { - _timeoutTimer?.cancel(); - _timeoutTimer = null; - if (!_isCompleted) { - _completer.completeError(StateError('CompleterManager disposed')); - _isCompleted = true; - } - } -} diff --git a/lib/src/token_source/caching.dart b/lib/src/token_source/caching.dart index 49903c61..e8905768 100644 --- a/lib/src/token_source/caching.dart +++ b/lib/src/token_source/caching.dart @@ -14,7 +14,7 @@ import 'dart:async'; -import '../support/completer_manager.dart'; +import '../support/reusable_completer.dart'; import 'jwt.dart'; import 'token_source.dart'; @@ -97,7 +97,7 @@ class CachingTokenSource implements TokenSourceConfigurable { final TokenSourceConfigurable _wrapped; final TokenStore _store; final TokenValidator _validator; - final Map> _inflightRequests = {}; + final Map> _inflightRequests = {}; /// Initialize a caching wrapper around any token source. /// @@ -114,28 +114,28 @@ class CachingTokenSource implements TokenSourceConfigurable { @override Future fetch(TokenRequestOptions options) async { - final existingManager = _inflightRequests[options]; - if (existingManager != null && existingManager.isActive) { - return existingManager.future; + final existingCompleter = _inflightRequests[options]; + if (existingCompleter != null && existingCompleter.isActive) { + return existingCompleter.future; } - final manager = existingManager ?? CompleterManager(); - _inflightRequests[options] = manager; - final resultFuture = manager.future; + final completer = existingCompleter ?? ReusableCompleter(); + _inflightRequests[options] = completer; + final resultFuture = completer.future; try { final cached = await _store.retrieve(); if (cached != null && cached.options == options && _validator(cached.options, cached.response)) { - manager.complete(cached.response); + completer.complete(cached.response); return resultFuture; } final response = await _wrapped.fetch(options); await _store.store(options, response); - manager.complete(response); + completer.complete(response); return resultFuture; } catch (e, stackTrace) { - manager.completeError(e, stackTrace); + completer.completeError(e, stackTrace); rethrow; } finally { _inflightRequests.remove(options); diff --git a/test/support/completer_manager_test.dart b/test/support/completer_manager_test.dart deleted file mode 100644 index f157b8d7..00000000 --- a/test/support/completer_manager_test.dart +++ /dev/null @@ -1,419 +0,0 @@ -// Copyright 2025 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:livekit_client/src/support/completer_manager.dart'; - -void main() { - group('CompleterManager', () { - late CompleterManager manager; - - setUp(() { - manager = CompleterManager(); - }); - - tearDown(() { - // Only dispose if not already completed or disposed - try { - if (manager.isActive) { - manager.complete('teardown'); - } - manager.dispose(); - } catch (_) { - // Already disposed, ignore - } - }); - - group('Basic Functionality', () { - test('should provide a future when accessed', () async { - final future = manager.future; - expect(future, isA>()); - expect(manager.isActive, isTrue); - expect(manager.isCompleted, isFalse); - - // Complete it to avoid tearDown issues - manager.complete('test'); - await expectLater(future, completion('test')); - }); - - test('should complete successfully with value', () async { - final future = manager.future; - final result = manager.complete('success'); - - expect(result, isTrue); - expect(manager.isCompleted, isTrue); - expect(manager.isActive, isFalse); - await expectLater(future, completion('success')); - }); - - test('should complete successfully without value', () async { - final manager = CompleterManager(); - final future = manager.future; - final result = manager.complete(); - - expect(result, isTrue); - expect(manager.isCompleted, isTrue); - await expectLater(future, completion(isNull)); - manager.dispose(); - }); - - test('should complete with error', () async { - final future = manager.future; - final testError = Exception('test error'); - final result = manager.completeError(testError); - - expect(result, isTrue); - expect(manager.isCompleted, isTrue); - expect(manager.isActive, isFalse); - await expectLater(future, throwsA(testError)); - }); - - test('should complete with error and stack trace', () async { - final future = manager.future; - final testError = Exception('test error'); - final stackTrace = StackTrace.current; - final result = manager.completeError(testError, stackTrace); - - expect(result, isTrue); - expect(manager.isCompleted, isTrue); - - try { - await future; - fail('Should have thrown an error'); - } catch (error, trace) { - expect(error, equals(testError)); - expect(trace, equals(stackTrace)); - } - }); - - test('should return false when completing already completed manager', () { - manager.complete('first'); - final result1 = manager.complete('second'); - final result2 = manager.completeError(Exception('error')); - - expect(result1, isFalse); - expect(result2, isFalse); - }); - }); - - group('State Properties', () { - test('initial state should be active and not completed', () { - expect(manager.isActive, isTrue); - expect(manager.isCompleted, isFalse); - }); - - test('should remain active after accessing future', () async { - final future = manager.future; - expect(manager.isActive, isTrue); - expect(manager.isCompleted, isFalse); - - // Complete it to avoid tearDown issues - manager.complete('test'); - await expectLater(future, completion('test')); - }); - - test('should be completed after completion', () async { - final future = manager.future; - manager.complete('done'); - - expect(manager.isActive, isFalse); - expect(manager.isCompleted, isTrue); - await expectLater(future, completion('done')); - }); - - test('should be completed after error completion', () async { - final future = manager.future; - final testError = Exception('error'); - manager.completeError(testError); - - expect(manager.isActive, isFalse); - expect(manager.isCompleted, isTrue); - await expectLater(future, throwsA(testError)); - }); - }); - - group('Reusability', () { - test('should create new future after previous completion', () async { - // First use - final future1 = manager.future; - manager.complete('first'); - await expectLater(future1, completion('first')); - - // Second use - should get new future - final future2 = manager.future; - expect(future2, isNot(same(future1))); - expect(manager.isActive, isTrue); - expect(manager.isCompleted, isFalse); - - manager.complete('second'); - await expectLater(future2, completion('second')); - }); - - test('should reset and be reusable', () async { - // First use - final future1 = manager.future; - manager.complete('first'); - await expectLater(future1, completion('first')); - - // Reset - note that reset creates a new completer, so it's not active until future is accessed - manager.reset(); - expect(manager.isCompleted, isFalse); - // After reset, manager is ready but not active until future is accessed - - // Second use after reset - final future2 = manager.future; - expect(manager.isActive, isTrue); - manager.complete('second'); - await expectLater(future2, completion('second')); - }); - - test('should reset even when active', () async { - final future1 = manager.future; - expect(manager.isActive, isTrue); - - manager.reset(); - expect(manager.isCompleted, isFalse); - // After reset, manager is ready but not active until future is accessed - - final future2 = manager.future; - expect(manager.isActive, isTrue); - expect(future2, isNot(same(future1))); - - // Complete it to avoid tearDown issues - manager.complete('test'); - await expectLater(future2, completion('test')); - }); - }); - - group('Timeout Functionality', () { - test('should timeout with default message', () async { - final future = manager.future; - manager.setTimer(Duration(milliseconds: 10)); - - await expectLater( - future, - throwsA(isA()), - ); - expect(manager.isCompleted, isTrue); - }); - - test('should timeout with custom message', () async { - final future = manager.future; - const customMessage = 'Custom timeout message'; - manager.setTimer(Duration(milliseconds: 10), timeoutReason: customMessage); - - try { - await future; - fail('Should have thrown TimeoutException'); - } catch (error) { - expect(error, isA()); - expect((error as TimeoutException).message, contains(customMessage)); - } - }); - - test('should cancel timeout on manual completion', () async { - final future = manager.future; - manager.setTimer(Duration(milliseconds: 100)); - - // Complete before timeout - manager.complete('completed'); - await expectLater(future, completion('completed')); - - // Wait longer than timeout to ensure it was cancelled - await Future.delayed(Duration(milliseconds: 150)); - // If we get here without additional errors, timeout was cancelled - }); - - test('should cancel timeout on error completion', () async { - final future = manager.future; - manager.setTimer(Duration(milliseconds: 100)); - - // Complete with error before timeout - final testError = Exception('test error'); - manager.completeError(testError); - await expectLater(future, throwsA(testError)); - - // Wait longer than timeout to ensure it was cancelled - await Future.delayed(Duration(milliseconds: 150)); - // If we get here without additional errors, timeout was cancelled - }); - - test('should replace previous timeout when setting new one', () async { - final future = manager.future; - manager.setTimer(Duration(milliseconds: 200)); - manager.setTimer(Duration(milliseconds: 10)); // This should replace the previous one - - await expectLater( - future, - throwsA(isA()), - ); - }); - - test('should not set timeout on completed manager', () async { - final future = manager.future; - manager.complete('done'); - await expectLater(future, completion('done')); - - // This should not throw or affect anything - manager.setTimer(Duration(milliseconds: 10)); - - // Verify still completed - expect(manager.isCompleted, isTrue); - }); - - test('should set timeout on active completer', () async { - // Manager is active by default now - expect(manager.isActive, isTrue); - - final future = manager.future; - manager.setTimer(Duration(milliseconds: 10)); - - // Should timeout - await expectLater(future, throwsA(isA())); - }); - }); - - group('Disposal', () { - test('should complete with error when disposed while active', () async { - final future = manager.future; - expect(manager.isActive, isTrue); - - manager.dispose(); - - await expectLater( - future, - throwsA(isA()), - ); - expect(manager.isCompleted, isTrue); - }); - - test('should not affect already completed manager', () async { - final future = manager.future; - manager.complete('done'); - await expectLater(future, completion('done')); - - // Dispose should not throw or change state - manager.dispose(); - expect(manager.isCompleted, isTrue); - }); - - test('should cancel timeout on dispose', () async { - final future = manager.future; - manager.setTimer(Duration(milliseconds: 10)); - - manager.dispose(); - - // Should complete with StateError, not TimeoutException - await expectLater( - future, - throwsA(isA()), - ); - }); - - test('should not allow operations after dispose', () async { - // Capture the future before disposing to handle the error - final future = manager.future; - manager.dispose(); - - // Dispose should complete with error - await expectLater(future, throwsA(isA())); - - // Further operations should return false - final result1 = manager.complete('test'); - final result2 = manager.completeError(Exception('error')); - - expect(result1, isFalse); - expect(result2, isFalse); - expect(manager.isCompleted, isTrue); - }); - }); - - group('Edge Cases', () { - test('should handle multiple future accesses for same completer', () async { - final future1 = manager.future; - final future2 = manager.future; - - expect(identical(future1, future2), isTrue); - expect(manager.isActive, isTrue); - - // Complete it to avoid tearDown issues - manager.complete('test'); - await expectLater(future1, completion('test')); - }); - - test('should handle rapid complete/reset cycles', () async { - for (int i = 0; i < 5; i++) { - final future = manager.future; - manager.complete('value_$i'); - await expectLater(future, completion('value_$i')); - if (i < 4) { - // Don't reset on the last iteration - manager.reset(); - } - } - }); - - test('should work with different generic types', () async { - final intManager = CompleterManager(); - final intFuture = intManager.future; - intManager.complete(42); - await expectLater(intFuture, completion(42)); - intManager.dispose(); - - final boolManager = CompleterManager(); - final boolFuture = boolManager.future; - boolManager.complete(true); - await expectLater(boolFuture, completion(isTrue)); - boolManager.dispose(); - }); - - test('should handle Future values in complete', () async { - final future = manager.future; - final futureValue = Future.value('async_result'); - manager.complete(futureValue); - - await expectLater(future, completion('async_result')); - }); - }); - - group('Thread Safety', () { - test('should handle concurrent operations safely', () async { - final futures = []; - - // Start multiple concurrent operations - for (int i = 0; i < 10; i++) { - futures.add(Future(() async { - final future = manager.future; - if (i == 0) { - // Only the first one should succeed in completing - await Future.delayed(Duration(milliseconds: 1)); - manager.complete('winner'); - } - return future; - })); - } - - final results = await Future.wait(futures, eagerError: false); - - // All should complete with the same value - for (final result in results) { - expect(result, equals('winner')); - } - }); - }); - }); -} From eeb6a2478098125e4c3eb2e307e16205c5ff0b64 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:08:26 +0700 Subject: [PATCH 06/19] changes --- .changes/token-source | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/token-source diff --git a/.changes/token-source b/.changes/token-source new file mode 100644 index 00000000..3f532304 --- /dev/null +++ b/.changes/token-source @@ -0,0 +1 @@ +patch type="added" "Token source API with caching, endpoint helpers" From 071d271b2c9470712b8a3e1b04d1b74fc73d6774 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:51:21 +0700 Subject: [PATCH 07/19] payload --- lib/src/token_source/jwt.dart | 232 ++++++++++++++++++++++++++---- test/token/token_source_test.dart | 148 ++++++++++++++++++- 2 files changed, 347 insertions(+), 33 deletions(-) diff --git a/lib/src/token_source/jwt.dart b/lib/src/token_source/jwt.dart index b241c774..d613ee7f 100644 --- a/lib/src/token_source/jwt.dart +++ b/lib/src/token_source/jwt.dart @@ -16,19 +16,208 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'token_source.dart'; +/// Parsed payload for a LiveKit-issued JWT. +class LiveKitJwtPayload { + LiveKitJwtPayload._(this._claims); + + factory LiveKitJwtPayload.fromClaims(Map claims) { + return LiveKitJwtPayload._(Map.from(claims)); + } + + static LiveKitJwtPayload? fromToken(String token) { + try { + final jwt = JWT.decode(token); + final claims = jwt.payload; + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(claims); + } + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(Map.from(claims)); + } + } on JWTException { + return null; + } + return null; + } + + final Map _claims; + + /// A readonly view of the raw JWT claims. + Map get claims => Map.unmodifiable(_claims); + + /// JWT issuer claim. + String? get issuer => _claims['iss'] as String?; + + /// JWT subject claim (participant identity). + String? get identity { + final sub = _claims['sub'] ?? _claims['identity']; + return sub is String ? sub : null; + } + + /// Display name for the participant. + String? get name => _claims['name'] as String?; + + /// Custom metadata associated with the participant. + String? get metadata => _claims['metadata'] as String?; + + /// Custom participant attributes. + Map? get attributes => _stringMapFor('attributes'); + + /// Video-specific grants embedded in the token, if present. + LiveKitVideoGrant? get video { + final raw = _claims['video']; + if (raw is Map) { + return LiveKitVideoGrant.fromJson(Map.from(raw)); + } + return null; + } + + /// Token expiration instant in UTC. + DateTime? get expiresAt => _dateTimeFor('exp'); + + /// Token not-before instant in UTC. + DateTime? get notBefore => _dateTimeFor('nbf'); + + /// Token issued-at instant in UTC. + DateTime? get issuedAt => _dateTimeFor('iat'); + + DateTime? _dateTimeFor(String key) { + final value = _claims[key]; + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value * 1000, isUtc: true); + } + if (value is num) { + return DateTime.fromMillisecondsSinceEpoch((value * 1000).round(), isUtc: true); + } + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return DateTime.fromMillisecondsSinceEpoch(parsed * 1000, isUtc: true); + } + } + return null; + } + + Map? _stringMapFor(String key) { + final value = _claims[key]; + if (value is Map) { + final result = {}; + value.forEach((dynamic k, dynamic v) { + if (k != null && v != null) { + result[k.toString()] = v.toString(); + } + }); + return result; + } + return null; + } +} + +/// LiveKit-specific video grants embedded within a JWT. +class LiveKitVideoGrant { + final String? room; + final bool? roomCreate; + final bool? roomJoin; + final bool? roomList; + final bool? roomRecord; + final bool? roomAdmin; + final bool? canPublish; + final bool? canSubscribe; + final bool? canPublishData; + final List? canPublishSources; + final bool? hidden; + final bool? recorder; + + const LiveKitVideoGrant({ + this.room, + this.roomCreate, + this.roomJoin, + this.roomList, + this.roomRecord, + this.roomAdmin, + this.canPublish, + this.canSubscribe, + this.canPublishData, + this.canPublishSources, + this.hidden, + this.recorder, + }); + + factory LiveKitVideoGrant.fromJson(Map json) => LiveKitVideoGrant( + room: json['room'] as String?, + roomCreate: json['room_create'] as bool?, + roomJoin: json['room_join'] as bool?, + roomList: json['room_list'] as bool?, + roomRecord: json['room_record'] as bool?, + roomAdmin: json['room_admin'] as bool?, + canPublish: json['can_publish'] as bool?, + canSubscribe: json['can_subscribe'] as bool?, + canPublishData: json['can_publish_data'] as bool?, + canPublishSources: (json['can_publish_sources'] as List?)?.map((dynamic item) => item.toString()).toList(), + hidden: json['hidden'] as bool?, + recorder: json['recorder'] as bool?, + ); +} + +extension TokenSourceJwt on TokenSourceResponse { + /// Decode the participant token and return the parsed payload, if valid. + LiveKitJwtPayload? get jwtPayload => LiveKitJwtPayload.fromToken(participantToken); + + /// Returns `true` when the participant token is valid (not expired and past its not-before time). + /// + /// [tolerance] allows treating tokens as expired ahead of their actual expiry to avoid edge cases. + /// [currentTime] is primarily intended for testing; it defaults to the current system time. + bool hasValidToken({Duration tolerance = const Duration(seconds: 60), DateTime? currentTime}) { + final payload = jwtPayload; + if (payload == null) { + return false; + } + + final nowUtc = (currentTime ?? DateTime.timestamp()).toUtc(); + + final notBefore = payload.notBefore; + if (notBefore != null && nowUtc.isBefore(notBefore)) { + return false; + } + + final expiresAt = payload.expiresAt; + if (expiresAt == null) { + return false; + } + + final comparisonInstant = nowUtc.add(tolerance); + if (!expiresAt.isAfter(comparisonInstant)) { + return false; + } + + return true; + } +} + /// Extension to extract LiveKit-specific claims from JWT tokens. extension LiveKitClaims on JWT { + LiveKitJwtPayload? get _liveKitPayload { + final claims = payload; + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(claims); + } + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(Map.from(claims)); + } + return null; + } + /// The display name for the participant. - String? get name => payload['name'] as String?; + String? get name => _liveKitPayload?.name; /// Custom metadata associated with the participant. - String? get metadata => payload['metadata'] as String?; + String? get metadata => _liveKitPayload?.metadata; /// Custom attributes for the participant. - Map? get attributes { - final attrs = payload['attributes']; - return attrs != null ? Map.from(attrs as Map) : null; - } + Map? get attributes => _liveKitPayload?.attributes; + + /// Video-specific grants embedded in the token. + LiveKitVideoGrant? get video => _liveKitPayload?.video; } /// Validates whether the JWT token in the response is expired or invalid. @@ -39,30 +228,13 @@ extension LiveKitClaims on JWT { /// This function checks: /// - Token validity (can be decoded) /// - Not-before time (nbf) - token is not yet valid -/// - Expiration time (exp) with 60 second tolerance +/// - Expiration time (exp) with configurable tolerance /// /// A missing expiration field is treated as invalid. -bool isResponseExpired(TokenSourceResponse response) { - try { - final jwt = JWT.decode(response.participantToken); - final payload = jwt.payload as Map; - - final now = DateTime.timestamp(); - - // Check notBefore (nbf) - token not yet valid - final nbf = payload['nbf'] as int?; - if (nbf != null) { - final nbfTime = DateTime.fromMillisecondsSinceEpoch(nbf * 1000, isUtc: true); - if (now.isBefore(nbfTime)) return true; - } - - // Check expiration (exp) with 60 second tolerance - final exp = payload['exp'] as int?; - if (exp == null) return true; // Missing exp = invalid - final expiresAt = DateTime.fromMillisecondsSinceEpoch(exp * 1000 - 60000, isUtc: true); - - return now.isAfter(expiresAt); - } on JWTException { - return true; // Invalid token = expired - } +bool isResponseExpired( + TokenSourceResponse response, { + Duration tolerance = const Duration(seconds: 60), + DateTime? currentTime, +}) { + return !response.hasValidToken(tolerance: tolerance, currentTime: currentTime); } diff --git a/test/token/token_source_test.dart b/test/token/token_source_test.dart index c72b54d8..21f96d1b 100644 --- a/test/token/token_source_test.dart +++ b/test/token/token_source_test.dart @@ -94,6 +94,113 @@ void main() { expect(isResponseExpired(response), isTrue); }); + + test('hasValidToken returns true for valid token', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(currentTime: now), isTrue); + }); + + test('hasValidToken returns false when exp is within tolerance window', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 30; // +30 seconds + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(currentTime: now), isFalse); + }); + + test('hasValidToken respects custom tolerance', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 30; // +30 seconds + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(tolerance: const Duration(seconds: 10), currentTime: now), isTrue); + expect(response.hasValidToken(tolerance: const Duration(seconds: 60), currentTime: now), isFalse); + }); + + test('hasValidToken respects not-before claim', () { + final now = DateTime.timestamp(); + final nbf = (now.millisecondsSinceEpoch ~/ 1000) + 120; // +2 minutes + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + + final token = _generateToken(nbf: nbf, exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(currentTime: now), isFalse); + expect(response.hasValidToken(currentTime: now.add(const Duration(minutes: 5))), isTrue); + }); + }); + + group('LiveKitJwtPayload', () { + test('parses claims and grants from token', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; + final token = _generateToken( + exp: exp, + iat: now.millisecondsSinceEpoch ~/ 1000, + issuer: 'livekit', + subject: 'participant-123', + name: 'Alice', + metadata: '{"key":"value"}', + attributes: {'role': 'host'}, + video: { + 'room': 'demo-room', + 'room_join': true, + 'room_create': true, + 'can_publish': true, + 'can_publish_data': true, + 'can_publish_sources': ['camera', 'screen'], + 'hidden': false, + 'recorder': true, + }, + ); + + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + final payload = response.jwtPayload; + expect(payload, isNotNull); + expect(payload!.issuer, 'livekit'); + expect(payload.identity, 'participant-123'); + expect(payload.name, 'Alice'); + expect(payload.metadata, '{"key":"value"}'); + expect(payload.attributes, {'role': 'host'}); + expect(payload.expiresAt, isNotNull); + expect(payload.issuedAt, isNotNull); + + final grant = payload.video; + expect(grant, isNotNull); + expect(grant!.room, 'demo-room'); + expect(grant.roomJoin, isTrue); + expect(grant.roomCreate, isTrue); + expect(grant.canPublish, isTrue); + expect(grant.canPublishData, isTrue); + expect(grant.canPublishSources, ['camera', 'screen']); + expect(grant.hidden, isFalse); + expect(grant.recorder, isTrue); + }); }); group('LiteralTokenSource', () { @@ -316,12 +423,31 @@ void main() { }); } -String _generateToken({int? nbf, int? exp, bool includeExp = true}) { +String _generateToken({ + int? nbf, + int? exp, + bool includeExp = true, + int? iat, + String? issuer, + String? subject, + String? name, + String? metadata, + Map? attributes, + Map? video, +}) { final payload = { - 'sub': 'test-participant', - 'video': {'room': 'test-room', 'roomJoin': true}, + 'sub': subject ?? 'test-participant', + 'video': video ?? + { + 'room': 'test-room', + 'room_join': true, + }, }; + if (issuer != null) { + payload['iss'] = issuer; + } + if (nbf != null) { payload['nbf'] = nbf; } @@ -330,6 +456,22 @@ String _generateToken({int? nbf, int? exp, bool includeExp = true}) { payload['exp'] = exp; } + if (iat != null) { + payload['iat'] = iat; + } + + if (name != null) { + payload['name'] = name; + } + + if (metadata != null) { + payload['metadata'] = metadata; + } + + if (attributes != null) { + payload['attributes'] = Map.from(attributes); + } + final jwt = JWT(payload); return jwt.sign(SecretKey('test-secret')); } From a8c8f89b1c123bb37cc0dccdac3430038484d0d5 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:09:51 +0700 Subject: [PATCH 08/19] exceptions etc --- lib/src/token_source/endpoint.dart | 36 +++++++++++++++++--- lib/src/token_source/sandbox.dart | 11 ++++-- test/token/endpoint_token_source_test.dart | 39 +++++++++++----------- test/token/token_source_test.dart | 10 ++++++ 4 files changed, 70 insertions(+), 26 deletions(-) diff --git a/lib/src/token_source/endpoint.dart b/lib/src/token_source/endpoint.dart index 782cdc79..c5210380 100644 --- a/lib/src/token_source/endpoint.dart +++ b/lib/src/token_source/endpoint.dart @@ -18,6 +18,29 @@ import 'package:http/http.dart' as http; import 'token_source.dart'; +/// Error thrown when the token server responds with a non-success HTTP status code. +class TokenSourceHttpException implements Exception { + /// The endpoint that returned the error. + final Uri uri; + + /// The HTTP status code returned by the endpoint. + final int statusCode; + + /// The raw response body returned by the endpoint. + final String body; + + const TokenSourceHttpException({ + required this.uri, + required this.statusCode, + required this.body, + }); + + @override + String toString() { + return 'TokenSourceHttpException(statusCode: $statusCode, uri: $uri, body: $body)'; + } +} + /// A token source that fetches credentials via HTTP requests from a custom backend. /// /// This implementation: @@ -29,7 +52,7 @@ import 'token_source.dart'; class EndpointTokenSource implements TokenSourceConfigurable { /// The URL endpoint for token generation. /// This should point to your backend service that generates LiveKit tokens. - final String url; + final Uri uri; /// The HTTP method to use for the token request (defaults to "POST"). final String method; @@ -47,16 +70,15 @@ class EndpointTokenSource implements TokenSourceConfigurable { /// - [headers]: Additional HTTP headers (optional) /// - [client]: Custom HTTP client for testing (optional) EndpointTokenSource({ - required this.url, + required Uri url, this.method = 'POST', this.headers = const {}, this.client, - }); + }) : uri = url; @override Future fetch(TokenRequestOptions options) async { final requestBody = jsonEncode(options.toRequest().toJson()); - final uri = Uri.parse(url); final requestHeaders = { 'Content-Type': 'application/json', ...headers, @@ -79,7 +101,11 @@ class EndpointTokenSource implements TokenSourceConfigurable { } if (response.statusCode < 200 || response.statusCode >= 300) { - throw Exception('Error generating token from endpoint $url: received ${response.statusCode} / ${response.body}'); + throw TokenSourceHttpException( + uri: uri, + statusCode: response.statusCode, + body: response.body, + ); } final responseBody = jsonDecode(response.body) as Map; diff --git a/lib/src/token_source/sandbox.dart b/lib/src/token_source/sandbox.dart index 9c15dfb0..2d582167 100644 --- a/lib/src/token_source/sandbox.dart +++ b/lib/src/token_source/sandbox.dart @@ -29,9 +29,16 @@ class SandboxTokenSource extends EndpointTokenSource { SandboxTokenSource({ required String sandboxId, }) : super( - url: 'https://cloud-api.livekit.io/api/v2/sandbox/connection-details', + url: Uri.parse('https://cloud-api.livekit.io/api/v2/sandbox/connection-details'), headers: { - 'X-Sandbox-ID': sandboxId, + 'X-Sandbox-ID': _sanitizeSandboxId(sandboxId), }, ); } + +String _sanitizeSandboxId(String sandboxId) { + var sanitized = sandboxId; + sanitized = sanitized.replaceFirst(RegExp(r'^[^a-zA-Z0-9]+'), ''); + sanitized = sanitized.replaceFirst(RegExp(r'[^a-zA-Z0-9]+$'), ''); + return sanitized; +} diff --git a/test/token/endpoint_token_source_test.dart b/test/token/endpoint_token_source_test.dart index a42f9afd..dc1991fb 100644 --- a/test/token/endpoint_token_source_test.dart +++ b/test/token/endpoint_token_source_test.dart @@ -41,7 +41,7 @@ void main() { }); final source = EndpointTokenSource( - url: 'https://example.com/token', + url: Uri.parse('https://example.com/token'), method: 'POST', headers: {'hello': 'world'}, client: mockClient, @@ -99,7 +99,7 @@ void main() { }); final source = EndpointTokenSource( - url: 'https://example.com/token', + url: Uri.parse('https://example.com/token'), method: 'GET', client: mockClient, ); @@ -127,7 +127,7 @@ void main() { }); final source = EndpointTokenSource( - url: 'https://example.com/token', + url: Uri.parse('https://example.com/token'), client: mockClient, ); @@ -151,7 +151,7 @@ void main() { }); final source = EndpointTokenSource( - url: 'https://example.com/token', + url: Uri.parse('https://example.com/token'), client: mockClient, ); @@ -175,7 +175,7 @@ void main() { }); final source = EndpointTokenSource( - url: 'https://example.com/token', + url: Uri.parse('https://example.com/token'), client: mockClient, ); @@ -187,43 +187,44 @@ void main() { expect(response.roomName, isNull); }); - test('error response throws exception', () async { + test('error response throws structured exception', () async { final mockClient = MockClient((request) async { return http.Response('Not Found', 404); }); final source = EndpointTokenSource( - url: 'https://example.com/token', + url: Uri.parse('https://example.com/token'), client: mockClient, ); expect( () => source.fetch(const TokenRequestOptions()), - throwsA(isA().having( - (e) => e.toString(), - 'message', - contains('404'), - )), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 404) + .having((e) => e.body, 'body', 'Not Found') + .having((e) => e.uri.toString(), 'uri', 'https://example.com/token'), + ), ); }); - test('server error response throws exception', () async { + test('server error response throws structured exception', () async { final mockClient = MockClient((request) async { return http.Response('Internal Server Error', 500); }); final source = EndpointTokenSource( - url: 'https://example.com/token', + url: Uri.parse('https://example.com/token'), client: mockClient, ); expect( () => source.fetch(const TokenRequestOptions()), - throwsA(isA().having( - (e) => e.toString(), - 'message', - contains('500'), - )), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 500) + .having((e) => e.body, 'body', 'Internal Server Error'), + ), ); }); }); diff --git a/test/token/token_source_test.dart b/test/token/token_source_test.dart index 21f96d1b..5de6093e 100644 --- a/test/token/token_source_test.dart +++ b/test/token/token_source_test.dart @@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/src/token_source/custom.dart'; import 'package:livekit_client/src/token_source/jwt.dart'; import 'package:livekit_client/src/token_source/literal.dart'; +import 'package:livekit_client/src/token_source/sandbox.dart'; import 'package:livekit_client/src/token_source/room_configuration.dart'; import 'package:livekit_client/src/token_source/token_source.dart'; @@ -203,6 +204,15 @@ void main() { }); }); + group('SandboxTokenSource', () { + test('sanitizes sandbox id and uses default base URL', () { + final source = SandboxTokenSource(sandboxId: ' sandbox-123 '); + + expect(source.uri.toString(), 'https://cloud-api.livekit.io/api/v2/sandbox/connection-details'); + expect(source.headers['X-Sandbox-ID'], 'sandbox-123'); + }); + }); + group('LiteralTokenSource', () { test('returns fixed response', () async { final source = LiteralTokenSource( From 88e051d924bc607f9d5dd36a5225f95b326c2387 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:25:01 +0700 Subject: [PATCH 09/19] minor adjustments --- lib/src/token_source/caching.dart | 2 +- lib/src/token_source/custom.dart | 5 +---- lib/src/token_source/token_source.dart | 2 +- test/token/caching_token_source_test.dart | 4 +--- test/token/endpoint_token_source_test.dart | 8 ++++++-- test/token/token_source_test.dart | 7 ++++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/src/token_source/caching.dart b/lib/src/token_source/caching.dart index e8905768..f11d9fa0 100644 --- a/lib/src/token_source/caching.dart +++ b/lib/src/token_source/caching.dart @@ -164,7 +164,7 @@ extension CachedTokenSource on TokenSourceConfigurable { /// - store: The store implementation to use for caching (defaults to in-memory store) /// - validator: A function to determine if cached credentials are still valid (defaults to JWT expiration check) /// - Returns: A caching token source that wraps this token source - TokenSourceConfigurable cached({ + CachingTokenSource cached({ TokenStore? store, TokenValidator? validator, }) => diff --git a/lib/src/token_source/custom.dart b/lib/src/token_source/custom.dart index ba4b7f35..424fc2ed 100644 --- a/lib/src/token_source/custom.dart +++ b/lib/src/token_source/custom.dart @@ -31,8 +31,5 @@ class CustomTokenSource implements TokenSourceConfigurable { CustomTokenSource(CustomTokenFunction function) : _function = function; @override - Future fetch([TokenRequestOptions? options]) async { - final requestOptions = options ?? const TokenRequestOptions(); - return await _function(requestOptions); - } + Future fetch(TokenRequestOptions options) async => _function(options); } diff --git a/lib/src/token_source/token_source.dart b/lib/src/token_source/token_source.dart index 0bfa2439..46137d53 100644 --- a/lib/src/token_source/token_source.dart +++ b/lib/src/token_source/token_source.dart @@ -61,7 +61,7 @@ class TokenRequestOptions { participantIdentity: participantIdentity, participantMetadata: participantMetadata, participantAttributes: participantAttributes, - roomConfiguration: agents != null ? RoomConfiguration(agents: agents) : null, + roomConfiguration: RoomConfiguration(agents: agents), ); } diff --git a/test/token/caching_token_source_test.dart b/test/token/caching_token_source_test.dart index eed9ee7a..06469b91 100644 --- a/test/token/caching_token_source_test.dart +++ b/test/token/caching_token_source_test.dart @@ -333,9 +333,7 @@ class _MockTokenSource implements TokenSourceConfigurable { _MockTokenSource(this._fetchFn); @override - Future fetch([TokenRequestOptions? options]) { - return _fetchFn(options ?? const TokenRequestOptions()); - } + Future fetch(TokenRequestOptions options) => _fetchFn(options); } String _generateValidToken() { diff --git a/test/token/endpoint_token_source_test.dart b/test/token/endpoint_token_source_test.dart index dc1991fb..fd1da8dc 100644 --- a/test/token/endpoint_token_source_test.dart +++ b/test/token/endpoint_token_source_test.dart @@ -112,7 +112,7 @@ void main() { expect(capturedRequest, isNotNull); expect(capturedRequest!.method, 'GET'); // Body is always sent even for GET requests - expect(capturedRequest!.body, '{}'); + expect(capturedRequest!.body, '{"room_config":{}}'); }); test('accepts non-200 success responses', () async { @@ -298,7 +298,9 @@ void main() { final json = options.toRequest().toJson(); - expect(json, isEmpty); + expect(json.keys, contains('room_config')); + expect(json['room_config'], isMap); + expect((json['room_config'] as Map), isEmpty); }); test('only includes non-null fields', () { @@ -313,6 +315,8 @@ void main() { expect(json.containsKey('room_name'), isTrue); expect(json.containsKey('participant_name'), isFalse); expect(json.containsKey('participant_identity'), isTrue); + expect(json.containsKey('room_config'), isTrue); + expect((json['room_config'] as Map), isEmpty); }); }); diff --git a/test/token/token_source_test.dart b/test/token/token_source_test.dart index 5de6093e..40f684d1 100644 --- a/test/token/token_source_test.dart +++ b/test/token/token_source_test.dart @@ -18,8 +18,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/src/token_source/custom.dart'; import 'package:livekit_client/src/token_source/jwt.dart'; import 'package:livekit_client/src/token_source/literal.dart'; -import 'package:livekit_client/src/token_source/sandbox.dart'; import 'package:livekit_client/src/token_source/room_configuration.dart'; +import 'package:livekit_client/src/token_source/sandbox.dart'; import 'package:livekit_client/src/token_source/token_source.dart'; void main() { @@ -354,7 +354,7 @@ void main() { expect(request.roomConfiguration!.agents![0].metadata, '{"key":"value"}'); }); - test('toRequest() creates null roomConfiguration when no agent fields', () { + test('toRequest() creates empty roomConfiguration when no agent fields', () { const options = TokenRequestOptions( roomName: 'test-room', participantName: 'test-participant', @@ -364,7 +364,8 @@ void main() { expect(request.roomName, 'test-room'); expect(request.participantName, 'test-participant'); - expect(request.roomConfiguration, isNull); + expect(request.roomConfiguration, isNotNull); + expect(request.roomConfiguration!.agents, isNull); }); test('TokenSourceRequest.toJson() produces correct wire format', () { From 3adf1e504426f269666602e0c4423af422061054 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:24:33 +0700 Subject: [PATCH 10/19] implementation 1 --- lib/livekit_client.dart | 9 + lib/src/agent/agent.dart | 200 +++++++++ lib/src/agent/chat/message.dart | 133 ++++++ lib/src/agent/chat/message_receiver.dart | 24 + lib/src/agent/chat/message_sender.dart | 20 + lib/src/agent/chat/text_message_sender.dart | 85 ++++ .../chat/transcription_stream_receiver.dart | 280 ++++++++++++ lib/src/agent/room_agent.dart | 35 ++ lib/src/agent/session.dart | 419 ++++++++++++++++++ lib/src/agent/session_options.dart | 50 +++ 10 files changed, 1255 insertions(+) create mode 100644 lib/src/agent/agent.dart create mode 100644 lib/src/agent/chat/message.dart create mode 100644 lib/src/agent/chat/message_receiver.dart create mode 100644 lib/src/agent/chat/message_sender.dart create mode 100644 lib/src/agent/chat/text_message_sender.dart create mode 100644 lib/src/agent/chat/transcription_stream_receiver.dart create mode 100644 lib/src/agent/room_agent.dart create mode 100644 lib/src/agent/session.dart create mode 100644 lib/src/agent/session_options.dart diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 88f460c5..b835f74e 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -29,6 +29,15 @@ export 'src/livekit.dart'; export 'src/logger.dart'; export 'src/managers/event.dart'; export 'src/options.dart'; +export 'src/agent/agent.dart'; +export 'src/agent/session.dart'; +export 'src/agent/session_options.dart'; +export 'src/agent/chat/message.dart'; +export 'src/agent/chat/message_sender.dart'; +export 'src/agent/chat/message_receiver.dart'; +export 'src/agent/chat/text_message_sender.dart'; +export 'src/agent/chat/transcription_stream_receiver.dart'; +export 'src/agent/room_agent.dart'; export 'src/participant/local.dart'; export 'src/participant/participant.dart'; export 'src/participant/remote.dart' hide ParticipantCreationResult; diff --git a/lib/src/agent/agent.dart b/lib/src/agent/agent.dart new file mode 100644 index 00000000..42bb899b --- /dev/null +++ b/lib/src/agent/agent.dart @@ -0,0 +1,200 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +import '../participant/participant.dart'; +import '../participant/remote.dart'; +import '../publication/remote.dart'; +import '../track/remote/audio.dart'; +import '../track/remote/video.dart'; +import '../track/track.dart'; +import '../types/attribute_typings.dart'; +import '../types/other.dart'; + +/// Represents a LiveKit Agent. +/// +/// The [Agent] class models the state of a LiveKit agent within a [Session]. +/// It exposes information about the agent's connection status, conversational +/// state, and the media tracks that belong to the agent. Consumers should +/// observe [Agent] to update their UI when the agent connects, disconnects, +/// or transitions between conversational states such as listening, thinking, +/// and speaking. +/// +/// The associated [Participant]'s attributes are inspected to derive the +/// agent-specific metadata (such as [agentState]). Audio and avatar video +/// tracks are picked from the agent participant and its associated avatar +/// worker (if any). +class Agent extends ChangeNotifier { + Agent(); + + AgentFailure? get error => _error; + AgentFailure? _error; + + /// The current conversational state of the agent. + AgentState? get agentState => _agentState; + AgentState? _agentState; + + /// The agent's audio track, if available. + RemoteAudioTrack? get audioTrack => _audioTrack; + RemoteAudioTrack? _audioTrack; + + /// The agent's avatar video track, if available. + RemoteVideoTrack? get avatarVideoTrack => _avatarVideoTrack; + RemoteVideoTrack? _avatarVideoTrack; + + /// Indicates whether the agent is connected. + bool get isConnected => switch (_state) { + _AgentLifecycle.connected => true, + _AgentLifecycle.connecting => false, + _AgentLifecycle.disconnected => false, + _AgentLifecycle.failed => false, + }; + + /// Whether the agent is buffering audio prior to connecting. + bool get isBuffering => _state == _AgentLifecycle.connecting && _isBuffering; + + _AgentLifecycle _state = _AgentLifecycle.disconnected; + bool _isBuffering = false; + + /// Marks the agent as disconnected. + void disconnected() { + if (_state == _AgentLifecycle.disconnected && + _agentState == null && + _audioTrack == null && + _avatarVideoTrack == null && + _error == null) { + return; + } + _state = _AgentLifecycle.disconnected; + _isBuffering = false; + _agentState = null; + _audioTrack = null; + _avatarVideoTrack = null; + _error = null; + notifyListeners(); + } + + /// Marks the agent as connecting. + void connecting({required bool buffering}) { + _state = _AgentLifecycle.connecting; + _isBuffering = buffering; + _error = null; + notifyListeners(); + } + + /// Marks the agent as failed. + void failed(AgentFailure failure) { + _state = _AgentLifecycle.failed; + _isBuffering = false; + _error = failure; + notifyListeners(); + } + + /// Updates the agent with information from the connected [participant]. + void connected(RemoteParticipant participant) { + final AgentState? nextAgentState = _readAgentState(participant); + final RemoteAudioTrack? nextAudioTrack = _resolveAudioTrack(participant); + final RemoteVideoTrack? nextAvatarTrack = _resolveAvatarVideoTrack(participant); + + final bool shouldNotify = _state != _AgentLifecycle.connected || + _agentState != nextAgentState || + !identical(_audioTrack, nextAudioTrack) || + !identical(_avatarVideoTrack, nextAvatarTrack) || + _error != null || + _isBuffering; + + _state = _AgentLifecycle.connected; + _isBuffering = false; + _error = null; + _agentState = nextAgentState; + _audioTrack = nextAudioTrack; + _avatarVideoTrack = nextAvatarTrack; + + if (shouldNotify) { + notifyListeners(); + } + } + + AgentState? _readAgentState(Participant participant) { + final rawState = participant.attributes[_AttributeKeys.agentState]; + if (rawState == null) { + return null; + } + switch (rawState) { + case 'idle': + return AgentState.IDLE; + case 'initializing': + return AgentState.INITIALIZING; + case 'listening': + return AgentState.LISTENING; + case 'speaking': + return AgentState.SPEAKING; + case 'thinking': + return AgentState.THINKING; + default: + return null; + } + } + + RemoteAudioTrack? _resolveAudioTrack(RemoteParticipant participant) { + final publication = participant.audioTrackPublications.firstWhereOrNull( + (pub) => pub.source == TrackSource.microphone, + ); + return publication?.track; + } + + RemoteVideoTrack? _resolveAvatarVideoTrack(RemoteParticipant participant) { + final avatarWorker = _findAvatarWorker(participant); + if (avatarWorker == null) { + return null; + } + final publication = avatarWorker.videoTrackPublications.firstWhereOrNull( + (pub) => pub.source == TrackSource.camera, + ); + return publication?.track; + } + + RemoteParticipant? _findAvatarWorker(RemoteParticipant participant) { + final publishOnBehalf = participant.identity; + final room = participant.room; + return room.remoteParticipants.values.firstWhereOrNull( + (p) => p.attributes[_AttributeKeys.publishOnBehalf] == publishOnBehalf, + ); + } +} + +/// Describes why an [Agent] failed to connect. +enum AgentFailure { + /// The agent did not connect within the allotted timeout. + timeout; + + /// A human-readable error message. + String get message => switch (this) { + AgentFailure.timeout => 'Agent did not connect', + }; +} + +enum _AgentLifecycle { + disconnected, + connecting, + connected, + failed, +} + +class _AttributeKeys { + static const agentState = 'lk.agent.state'; + static const publishOnBehalf = 'lk.publish_on_behalf'; +} diff --git a/lib/src/agent/chat/message.dart b/lib/src/agent/chat/message.dart new file mode 100644 index 00000000..3c59c40a --- /dev/null +++ b/lib/src/agent/chat/message.dart @@ -0,0 +1,133 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/foundation.dart'; + +/// A message received from the agent. +@immutable +class ReceivedMessage { + const ReceivedMessage({ + required this.id, + required this.timestamp, + required this.content, + }); + + final String id; + final DateTime timestamp; + final ReceivedMessageContent content; + + ReceivedMessage copyWith({ + String? id, + DateTime? timestamp, + ReceivedMessageContent? content, + }) { + return ReceivedMessage( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + content: content ?? this.content, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ReceivedMessage && + other.id == id && + other.timestamp == timestamp && + other.content == content; + } + + @override + int get hashCode => Object.hash(id, timestamp, content); +} + +/// Base class for message content types that can be received from the agent. +sealed class ReceivedMessageContent { + const ReceivedMessageContent(); + + /// Textual representation of the content. + String get text; +} + +/// A transcript emitted by the agent. +class AgentTranscript extends ReceivedMessageContent { + const AgentTranscript(this.text); + + @override + final String text; + + @override + bool operator ==(Object other) => other is AgentTranscript && other.text == text; + + @override + int get hashCode => text.hashCode; +} + +/// A transcript emitted for the user (e.g., speech-to-text). +class UserTranscript extends ReceivedMessageContent { + const UserTranscript(this.text); + + @override + final String text; + + @override + bool operator ==(Object other) => other is UserTranscript && other.text == text; + + @override + int get hashCode => text.hashCode; +} + +/// A message that originated from user input (loopback). +class UserInput extends ReceivedMessageContent { + const UserInput(this.text); + + @override + final String text; + + @override + bool operator ==(Object other) => other is UserInput && other.text == text; + + @override + int get hashCode => text.hashCode; +} + +/// A message sent to the agent. +@immutable +class SentMessage { + const SentMessage({ + required this.id, + required this.timestamp, + required this.content, + }); + + final String id; + final DateTime timestamp; + final SentMessageContent content; +} + +/// Base class for message content types that can be sent to the agent. +sealed class SentMessageContent { + const SentMessageContent(); + + /// Textual representation of the content. + String get text; +} + +/// User-provided text input. +class SentUserInput extends SentMessageContent { + const SentUserInput(this.text); + + @override + final String text; +} diff --git a/lib/src/agent/chat/message_receiver.dart b/lib/src/agent/chat/message_receiver.dart new file mode 100644 index 00000000..9c47825c --- /dev/null +++ b/lib/src/agent/chat/message_receiver.dart @@ -0,0 +1,24 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'message.dart'; + +/// Receives messages produced by the agent. +abstract class MessageReceiver { + Stream messages(); + + Future dispose(); +} diff --git a/lib/src/agent/chat/message_sender.dart b/lib/src/agent/chat/message_sender.dart new file mode 100644 index 00000000..a9559741 --- /dev/null +++ b/lib/src/agent/chat/message_sender.dart @@ -0,0 +1,20 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'message.dart'; + +/// Sends messages to the agent. +abstract class MessageSender { + Future send(SentMessage message); +} diff --git a/lib/src/agent/chat/text_message_sender.dart b/lib/src/agent/chat/text_message_sender.dart new file mode 100644 index 00000000..f83f73c1 --- /dev/null +++ b/lib/src/agent/chat/text_message_sender.dart @@ -0,0 +1,85 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:uuid/uuid.dart'; + +import '../../core/room.dart'; +import '../../participant/local.dart'; +import '../../types/data_stream.dart'; +import 'message.dart'; +import 'message_receiver.dart'; +import 'message_sender.dart'; + +/// Sends text messages to the agent and emits a loopback message so the UI +/// can reflect the user's input immediately. +class TextMessageSender implements MessageSender, MessageReceiver { + TextMessageSender({ + required Room room, + this.topic = 'lk.chat', + }) : _room = room { + _controller = StreamController.broadcast(); + } + + final Room _room; + final String topic; + late final StreamController _controller; + + @override + Stream messages() => _controller.stream; + + @override + Future dispose() async { + await _controller.close(); + } + + @override + Future send(SentMessage message) async { + final content = message.content; + if (content is! SentUserInput) { + return; + } + + final LocalParticipant? localParticipant = _room.localParticipant; + if (localParticipant == null) { + throw StateError('Cannot send a message before connecting to the room.'); + } + + await localParticipant.sendText( + content.text, + options: SendTextOptions(topic: topic), + ); + + if (!_controller.isClosed) { + _controller.add( + ReceivedMessage( + id: message.id, + timestamp: message.timestamp, + content: UserInput(content.text), + ), + ); + } + } + + /// Convenience helper for sending text without constructing a [SentMessage]. + Future sendText(String text) { + final message = SentMessage( + id: const Uuid().v4(), + timestamp: DateTime.timestamp(), + content: SentUserInput(text), + ); + return send(message); + } +} diff --git a/lib/src/agent/chat/transcription_stream_receiver.dart b/lib/src/agent/chat/transcription_stream_receiver.dart new file mode 100644 index 00000000..f2607d3b --- /dev/null +++ b/lib/src/agent/chat/transcription_stream_receiver.dart @@ -0,0 +1,280 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; + +import '../../core/room.dart'; +import '../../data_stream/stream_reader.dart'; +import '../../logger.dart'; +import '../../participant/participant.dart'; +import 'message.dart'; +import 'message_receiver.dart'; + +/// Converts LiveKit transcription text streams into [ReceivedMessage]s. +/// +/// Each stream corresponds to a single message (agent or user). The stream +/// yields textual updates which are aggregated until the message is finalized. +/// When a new message for the same participant arrives, previous partial +/// content is purged so that memory usage remains bounded. +class TranscriptionStreamReceiver implements MessageReceiver { + TranscriptionStreamReceiver({ + required Room room, + this.topic = 'lk.transcription', + }) : _room = room; + + final Room _room; + final String topic; + + StreamController? _controller; + bool _registered = false; + bool _controllerClosed = false; + + final Map<_PartialMessageId, _PartialMessage> _partialMessages = HashMap(); + + @override + Stream messages() { + if (_controller != null) { + return _controller!.stream; + } + + _controller = StreamController.broadcast( + onListen: _registerHandler, + onCancel: _handleCancel, + ); + _controllerClosed = false; + return _controller!.stream; + } + + void _registerHandler() { + if (_registered) { + return; + } + _registered = true; + + _room.registerTextStreamHandler(topic, (TextStreamReader reader, String participantIdentity) { + reader.listen( + (chunk) { + if (chunk.content.isEmpty) { + return; + } + + final String text; + try { + text = utf8.decode(chunk.content); + } catch (error) { + logger.warning('Failed to decode transcription chunk: $error'); + return; + } + + if (text.isEmpty) { + return; + } + + final message = _processIncoming( + text, + reader, + participantIdentity, + ); + if (!_controller!.isClosed) { + _controller!.add(message); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!_controller!.isClosed) { + _controller!.addError(error, stackTrace); + } + }, + onDone: () { + final attributes = reader.info.attributes; + final segmentId = _extractSegmentId(attributes, reader.info.id); + final key = _PartialMessageId(segmentId: segmentId, participantId: participantIdentity); + _partialMessages.remove(key); + }, + cancelOnError: true, + ); + }); + } + + void _handleCancel() { + if (_registered) { + _room.unregisterTextStreamHandler(topic); + _registered = false; + } + _partialMessages.clear(); + if (_controllerClosed) { + return; + } + _controllerClosed = true; + _controller?.close(); + _controller = null; + } + + ReceivedMessage _processIncoming( + String chunk, + TextStreamReader reader, + String participantIdentity, + ) { + final attributes = _TranscriptionAttributes.from(reader.info.attributes); + final segmentId = _extractSegmentId(reader.info.attributes, reader.info.id); + final key = _PartialMessageId(segmentId: segmentId, participantId: participantIdentity); + final currentStreamId = reader.info.id; + + final DateTime timestamp = + DateTime.fromMillisecondsSinceEpoch(reader.info.timestamp, isUtc: true).toLocal(); + + final existing = _partialMessages[key]; + if (existing != null) { + if (existing.streamId == currentStreamId) { + existing.append(chunk); + } else { + existing.replace(chunk, currentStreamId); + } + } else { + _partialMessages[key] = _PartialMessage( + content: chunk, + timestamp: timestamp, + streamId: currentStreamId, + ); + _cleanupPreviousTurn(participantIdentity, segmentId); + } + + final currentPartial = _partialMessages[key]; + + if (attributes.isFinal == true) { + _partialMessages.remove(key); + } + + final partial = attributes.isFinal == true ? currentPartial : _partialMessages[key]; + final displayContent = partial?.content ?? chunk; + final displayTimestamp = partial?.timestamp ?? timestamp; + final isLocalParticipant = _room.localParticipant?.identity == participantIdentity; + + final ReceivedMessageContent content = + isLocalParticipant ? UserTranscript(displayContent) : AgentTranscript(displayContent); + + return ReceivedMessage( + id: segmentId, + timestamp: displayTimestamp, + content: content, + ); + } + + void _cleanupPreviousTurn(String participantId, String currentSegmentId) { + final keysToRemove = _partialMessages.keys + .where((key) => key.participantId == participantId && key.segmentId != currentSegmentId) + .toList(growable: false); + + for (final key in keysToRemove) { + _partialMessages.remove(key); + } + } + + String _extractSegmentId(Map attributes, String fallback) { + return attributes[_AttributeKeys.segmentId] ?? fallback; + } + + @override + Future dispose() async { + if (_registered) { + _room.unregisterTextStreamHandler(topic); + _registered = false; + } + _partialMessages.clear(); + if (!_controllerClosed) { + _controllerClosed = true; + await _controller?.close(); + } + _controller = null; + } +} + +class _PartialMessageId { + _PartialMessageId({ + required this.segmentId, + required this.participantId, + }); + + final String segmentId; + final String participantId; + + @override + bool operator ==(Object other) => + other is _PartialMessageId && + other.segmentId == segmentId && + other.participantId == participantId; + + @override + int get hashCode => Object.hash(segmentId, participantId); +} + +class _PartialMessage { + _PartialMessage({ + required this.content, + required this.timestamp, + required this.streamId, + }); + + String content; + DateTime timestamp; + String streamId; + + void append(String chunk) { + content += chunk; + } + + void replace(String chunk, String newStreamId) { + content = chunk; + streamId = newStreamId; + } +} + +class _TranscriptionAttributes { + _TranscriptionAttributes({ + required this.segmentId, + required this.isFinal, + }); + + final String? segmentId; + final bool? isFinal; + + static _TranscriptionAttributes from(Map attributes) { + return _TranscriptionAttributes( + segmentId: attributes[_AttributeKeys.segmentId], + isFinal: _parseBool(attributes[_AttributeKeys.transcriptionFinal]), + ); + } + + static bool? _parseBool(String? value) { + if (value == null) { + return null; + } + final normalized = value.toLowerCase(); + if (normalized == 'true' || normalized == '1') { + return true; + } + if (normalized == 'false' || normalized == '0') { + return false; + } + return null; + } +} + +class _AttributeKeys { + static const segmentId = 'lk.segment_id'; + static const transcriptionFinal = 'lk.transcription_final'; +} diff --git a/lib/src/agent/room_agent.dart b/lib/src/agent/room_agent.dart new file mode 100644 index 00000000..a9162c91 --- /dev/null +++ b/lib/src/agent/room_agent.dart @@ -0,0 +1,35 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:collection/collection.dart'; + +import '../core/room.dart'; +import '../participant/remote.dart'; +import '../types/other.dart'; + +extension AgentRoom on Room { + /// All agent participants currently in the room. + Iterable get agentParticipants => remoteParticipants.values.where( + (participant) { + if (participant.kind != ParticipantKind.AGENT) { + return false; + } + final publishOnBehalf = participant.attributes['lk.publish_on_behalf']; + return publishOnBehalf == null || publishOnBehalf.isEmpty; + }, + ); + + /// The first agent participant in the room, if one exists. + RemoteParticipant? get agentParticipant => agentParticipants.firstOrNull; +} diff --git a/lib/src/agent/session.dart b/lib/src/agent/session.dart new file mode 100644 index 00000000..44ea93cf --- /dev/null +++ b/lib/src/agent/session.dart @@ -0,0 +1,419 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:collection'; + +import 'package:collection/collection.dart'; +import 'package:uuid/uuid.dart'; + +import '../core/room.dart'; +import '../core/room_preconnect.dart'; +import '../events.dart'; +import '../logger.dart'; +import '../managers/event.dart'; +import '../participant/remote.dart'; +import '../support/disposable.dart'; +import '../token_source/token_source.dart'; +import '../types/other.dart'; +import 'agent.dart'; +import 'chat/message.dart'; +import 'chat/message_receiver.dart'; +import 'chat/message_sender.dart'; +import 'chat/text_message_sender.dart'; +import 'chat/transcription_stream_receiver.dart'; +import 'room_agent.dart'; +import 'session_options.dart'; + +/// A [Session] represents a connection to a LiveKit Room that can contain an agent. +/// +/// A session encapsulates the lifecycle of connecting to a room, dispatching an +/// agent, and relaying messages between the user and the agent. It exposes +/// observable state such as the agent's connection status, any session errors, +/// and the ordered history of messages exchanged during the session. +/// +/// To use a session, provide a token source (fixed or configurable) and call +/// [start]. When finished, call [end] to disconnect from the room. Messages can +/// be sent with [sendText], and the message history can be inspected or restored +/// via [messages], [getMessageHistory], and [restoreMessageHistory]. +/// +/// The session is designed to be observed from Flutter widgets (it extends +/// [ChangeNotifier] through [DisposableChangeNotifier]) in the same way that the +/// Swift implementation conforms to `ObservableObject`. +class Session extends DisposableChangeNotifier { + Session._({ + required _TokenSourceConfiguration tokenSourceConfiguration, + required SessionOptions options, + List? senders, + List? receivers, + }) : _tokenSourceConfiguration = tokenSourceConfiguration, + _options = options, + room = options.room { + _agent.addListener(notifyListeners); + + final textMessageSender = TextMessageSender(room: room); + final resolvedSenders = senders ?? [textMessageSender]; + final resolvedReceivers = + receivers ?? [textMessageSender, TranscriptionStreamReceiver(room: room)]; + + _senders.addAll(resolvedSenders); + _receivers.addAll(resolvedReceivers); + + _observeRoom(); + _observeReceivers(); + + onDispose(() async { + _agent.removeListener(notifyListeners); + _roomListener?.dispose(); + await _cancelReceiverSubscriptions(); + await Future.wait(_receivers.toSet().map((receiver) => receiver.dispose())); + _agentTimeoutTimer?.cancel(); + }); + } + + /// Initializes a new [Session] with a fixed token source. + factory Session.fromFixedTokenSource( + TokenSourceFixed tokenSource, { + SessionOptions? options, + List? senders, + List? receivers, + }) { + return Session._( + tokenSourceConfiguration: _FixedTokenSourceConfiguration(tokenSource), + options: options ?? SessionOptions(), + senders: senders, + receivers: receivers, + ); + } + + /// Initializes a new [Session] with a configurable token source. + factory Session.fromConfigurableTokenSource( + TokenSourceConfigurable tokenSource, { + TokenRequestOptions tokenOptions = const TokenRequestOptions(), + SessionOptions? options, + List? senders, + List? receivers, + }) { + return Session._( + tokenSourceConfiguration: _ConfigurableTokenSourceConfiguration(tokenSource, tokenOptions), + options: options ?? SessionOptions(), + senders: senders, + receivers: receivers, + ); + } + + /// Creates a new [Session] configured for a specific agent. + factory Session.withAgent( + String agentName, { + String? agentMetadata, + required TokenSourceConfigurable tokenSource, + SessionOptions? options, + List? senders, + List? receivers, + }) { + return Session.fromConfigurableTokenSource( + tokenSource, + tokenOptions: TokenRequestOptions( + agentName: agentName, + agentMetadata: agentMetadata, + ), + options: options, + senders: senders, + receivers: receivers, + ); + } + + static final Uuid _uuid = const Uuid(); + + final Room room; + final SessionOptions _options; + final _TokenSourceConfiguration _tokenSourceConfiguration; + + final Agent _agent = Agent(); + Agent get agent => _agent; + + SessionError? get error => _error; + SessionError? _error; + + ConnectionState get connectionState => _connectionState; + ConnectionState _connectionState = ConnectionState.disconnected; + + bool get isConnected => switch (_connectionState) { + ConnectionState.connecting || ConnectionState.connected || ConnectionState.reconnecting => true, + ConnectionState.disconnected => false, + }; + + final LinkedHashMap _messages = LinkedHashMap(); + + List get messages => List.unmodifiable(_messages.values); + + final List _senders = []; + final List _receivers = []; + final List> _receiverSubscriptions = []; + + EventsListener? _roomListener; + Timer? _agentTimeoutTimer; + + /// Starts the session by fetching credentials and connecting to the room. + Future start() async { + if (room.connectionState != ConnectionState.disconnected) { + logger.info('Session.start() ignored: room already connecting or connected.'); + return; + } + + _setError(null); + _agentTimeoutTimer?.cancel(); + + final Duration timeout = _options.agentConnectTimeout; + + Future connect() async { + final response = await _tokenSourceConfiguration.fetch(); + await room.connect( + response.serverUrl, + response.participantToken, + ); + } + + try { + if (_options.preConnectAudio) { + await room.withPreConnectAudio( + () async { + _setConnectionState(ConnectionState.connecting); + _agent.connecting(buffering: true); + await connect(); + }, + timeout: timeout, + ); + } else { + _setConnectionState(ConnectionState.connecting); + _agent.connecting(buffering: false); + await connect(); + await room.localParticipant?.setMicrophoneEnabled(true); + } + + _agentTimeoutTimer = Timer(timeout, () { + if (isConnected && !_agent.isConnected) { + _agent.failed(AgentFailure.timeout); + } + }); + } catch (error, stackTrace) { + logger.warning('Session.start() failed: $error', error, stackTrace); + _setError(SessionError.connection(error)); + _setConnectionState(ConnectionState.disconnected); + _agent.disconnected(); + } + } + + /// Terminates the session and disconnects from the room. + Future end() async { + await room.disconnect(); + } + + /// Clears the last error. + void dismissError() { + _setError(null); + } + + /// Sends a text message to the agent. + /// + /// Returns the [SentMessage] if the message was sent by all senders, or + /// `null` if a sender failed. When a sender fails, the session error is set + /// to [SessionErrorKind.sender]. + Future sendText(String text) async { + final message = SentMessage( + id: _uuid.v4(), + timestamp: DateTime.timestamp(), + content: SentUserInput(text), + ); + + try { + for (final sender in _senders) { + await sender.send(message); + } + return message; + } catch (error, stackTrace) { + logger.warning('Session.sendText() failed: $error', error, stackTrace); + _setError(SessionError.sender(error)); + return null; + } + } + + /// Returns the message history. + List getMessageHistory() => List.unmodifiable(_messages.values); + + /// Restores the message history with the provided [messages]. + void restoreMessageHistory(List messages) { + _messages + ..clear() + ..addEntries( + messages.sorted((a, b) => a.timestamp.compareTo(b.timestamp)).map( + (message) => MapEntry(message.id, message), + ), + ); + notifyListeners(); + } + + void _observeRoom() { + final listener = room.createListener(); + listener.listen((event) async { + _handleRoomEvent(event); + }); + _roomListener = listener; + } + + void _observeReceivers() { + for (final receiver in _receivers) { + final subscription = receiver.messages().listen( + (message) { + final existing = _messages[message.id]; + final shouldNotify = existing != message; + _messages[message.id] = message; + if (shouldNotify) { + notifyListeners(); + } + }, + onError: (Object error, StackTrace stackTrace) { + logger.warning('Session receiver error: $error', error, stackTrace); + _setError(SessionError.receiver(error)); + }, + ); + _receiverSubscriptions.add(subscription); + } + } + + Future _cancelReceiverSubscriptions() async { + for (final subscription in _receiverSubscriptions) { + await subscription.cancel(); + } + _receiverSubscriptions.clear(); + } + + void _handleRoomEvent(RoomEvent event) { + if (event is RoomConnectedEvent || event is RoomReconnectedEvent) { + _setConnectionState(ConnectionState.connected); + } else if (event is RoomReconnectingEvent) { + _setConnectionState(ConnectionState.reconnecting); + } else if (event is RoomDisconnectedEvent) { + _setConnectionState(ConnectionState.disconnected); + _agent.disconnected(); + } + + switch (event) { + case ParticipantConnectedEvent _: + case ParticipantDisconnectedEvent _: + case ParticipantAttributesChanged _: + case TrackPublishedEvent _: + case TrackUnpublishedEvent _: + case RoomConnectedEvent _: + case RoomReconnectedEvent _: + case RoomReconnectingEvent _: + _updateAgent(); + default: + break; + } + } + + void _updateAgent() { + final connectionState = room.connectionState; + _setConnectionState(connectionState); + + if (connectionState == ConnectionState.disconnected) { + _agent.disconnected(); + return; + } + + final RemoteParticipant? firstAgent = room.agentParticipants.firstOrNull; + if (firstAgent != null) { + _agent.connected(firstAgent); + } else { + _agent.connecting(buffering: _options.preConnectAudio); + } + } + + void _setConnectionState(ConnectionState state) { + if (_connectionState == state) { + return; + } + _connectionState = state; + notifyListeners(); + } + + void _setError(SessionError? newError) { + if (_error == newError) { + return; + } + _error = newError; + notifyListeners(); + } +} + +enum SessionErrorKind { + connection, + sender, + receiver, +} + +/// Represents an error that occurred during a [Session]. +class SessionError { + SessionError._(this.kind, this.cause); + + final SessionErrorKind kind; + final Object cause; + + String get message => switch (kind) { + SessionErrorKind.connection => 'Connection failed: ${cause}', + SessionErrorKind.sender => 'Message sender failed: ${cause}', + SessionErrorKind.receiver => 'Message receiver failed: ${cause}', + }; + + static SessionError connection(Object cause) => SessionError._(SessionErrorKind.connection, cause); + + static SessionError sender(Object cause) => SessionError._(SessionErrorKind.sender, cause); + + static SessionError receiver(Object cause) => SessionError._(SessionErrorKind.receiver, cause); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SessionError && other.kind == kind && other.cause == cause; + } + + @override + int get hashCode => Object.hash(kind, cause); +} + +sealed class _TokenSourceConfiguration { + const _TokenSourceConfiguration(); + + Future fetch(); +} + +class _FixedTokenSourceConfiguration extends _TokenSourceConfiguration { + const _FixedTokenSourceConfiguration(this.source); + + final TokenSourceFixed source; + + @override + Future fetch() => source.fetch(); +} + +class _ConfigurableTokenSourceConfiguration extends _TokenSourceConfiguration { + const _ConfigurableTokenSourceConfiguration(this.source, this.options); + + final TokenSourceConfigurable source; + final TokenRequestOptions options; + + @override + Future fetch() => source.fetch(options); +} diff --git a/lib/src/agent/session_options.dart b/lib/src/agent/session_options.dart new file mode 100644 index 00000000..eb6d262c --- /dev/null +++ b/lib/src/agent/session_options.dart @@ -0,0 +1,50 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../core/room.dart'; + +/// Options for creating a [Session]. +class SessionOptions { + /// The underlying [Room] used by the session. + final Room room; + + /// Whether to enable audio pre-connect with [PreConnectAudioBuffer]. + /// + /// If enabled, the microphone is activated before connecting to the room. + /// Ensure microphone permissions are requested early in the app lifecycle so + /// that pre-connect can succeed without additional prompts. + final bool preConnectAudio; + + /// The timeout for the agent to connect. If exceeded, the agent transitions + /// to a failed state. + final Duration agentConnectTimeout; + + SessionOptions({ + Room? room, + this.preConnectAudio = true, + this.agentConnectTimeout = const Duration(seconds: 20), + }) : room = room ?? Room(); + + SessionOptions copyWith({ + Room? room, + bool? preConnectAudio, + Duration? agentConnectTimeout, + }) { + return SessionOptions( + room: room ?? this.room, + preConnectAudio: preConnectAudio ?? this.preConnectAudio, + agentConnectTimeout: agentConnectTimeout ?? this.agentConnectTimeout, + ); + } +} From 94bb84e57dffaa9bab5938221b4027819e4ef04b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:29:52 +0700 Subject: [PATCH 11/19] squash fixes --- lib/src/agent/agent.dart | 15 +- lib/src/agent/chat/message.dart | 5 +- .../chat/transcription_stream_receiver.dart | 50 +++-- lib/src/agent/constants.dart | 18 ++ lib/src/agent/room_agent.dart | 3 +- lib/src/agent/session.dart | 2 +- lib/src/types/attribute_typings.dart | 187 ++++++++++++------ 7 files changed, 182 insertions(+), 98 deletions(-) create mode 100644 lib/src/agent/constants.dart diff --git a/lib/src/agent/agent.dart b/lib/src/agent/agent.dart index 42bb899b..1aa4a6f7 100644 --- a/lib/src/agent/agent.dart +++ b/lib/src/agent/agent.dart @@ -12,17 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:collection/collection.dart'; + import '../participant/participant.dart'; import '../participant/remote.dart'; -import '../publication/remote.dart'; import '../track/remote/audio.dart'; import '../track/remote/video.dart'; -import '../track/track.dart'; import '../types/attribute_typings.dart'; import '../types/other.dart'; +import 'constants.dart'; /// Represents a LiveKit Agent. /// @@ -129,7 +129,7 @@ class Agent extends ChangeNotifier { } AgentState? _readAgentState(Participant participant) { - final rawState = participant.attributes[_AttributeKeys.agentState]; + final rawState = participant.attributes[lkAgentStateAttributeKey]; if (rawState == null) { return null; } @@ -171,7 +171,7 @@ class Agent extends ChangeNotifier { final publishOnBehalf = participant.identity; final room = participant.room; return room.remoteParticipants.values.firstWhereOrNull( - (p) => p.attributes[_AttributeKeys.publishOnBehalf] == publishOnBehalf, + (p) => p.attributes[lkPublishOnBehalfAttributeKey] == publishOnBehalf, ); } } @@ -193,8 +193,3 @@ enum _AgentLifecycle { connected, failed, } - -class _AttributeKeys { - static const agentState = 'lk.agent.state'; - static const publishOnBehalf = 'lk.publish_on_behalf'; -} diff --git a/lib/src/agent/chat/message.dart b/lib/src/agent/chat/message.dart index 3c59c40a..b744fee2 100644 --- a/lib/src/agent/chat/message.dart +++ b/lib/src/agent/chat/message.dart @@ -42,10 +42,7 @@ class ReceivedMessage { @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is ReceivedMessage && - other.id == id && - other.timestamp == timestamp && - other.content == content; + return other is ReceivedMessage && other.id == id && other.timestamp == timestamp && other.content == content; } @override diff --git a/lib/src/agent/chat/transcription_stream_receiver.dart b/lib/src/agent/chat/transcription_stream_receiver.dart index f2607d3b..0c4a6011 100644 --- a/lib/src/agent/chat/transcription_stream_receiver.dart +++ b/lib/src/agent/chat/transcription_stream_receiver.dart @@ -16,12 +16,10 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; -import 'package:collection/collection.dart'; - import '../../core/room.dart'; import '../../data_stream/stream_reader.dart'; import '../../logger.dart'; -import '../../participant/participant.dart'; +import '../../types/data_stream.dart'; import 'message.dart'; import 'message_receiver.dart'; @@ -69,6 +67,12 @@ class TranscriptionStreamReceiver implements MessageReceiver { _room.registerTextStreamHandler(topic, (TextStreamReader reader, String participantIdentity) { reader.listen( (chunk) { + final info = reader.info; + if (info == null) { + logger.warning('Received transcription chunk without metadata.'); + return; + } + if (chunk.content.isEmpty) { return; } @@ -87,7 +91,7 @@ class TranscriptionStreamReceiver implements MessageReceiver { final message = _processIncoming( text, - reader, + info, participantIdentity, ); if (!_controller!.isClosed) { @@ -100,10 +104,13 @@ class TranscriptionStreamReceiver implements MessageReceiver { } }, onDone: () { - final attributes = reader.info.attributes; - final segmentId = _extractSegmentId(attributes, reader.info.id); - final key = _PartialMessageId(segmentId: segmentId, participantId: participantIdentity); - _partialMessages.remove(key); + final info = reader.info; + if (info != null) { + final attributes = info.attributes; + final segmentId = _extractSegmentId(attributes, info.id); + final key = _PartialMessageId(segmentId: segmentId, participantId: participantIdentity); + _partialMessages.remove(key); + } }, cancelOnError: true, ); @@ -120,22 +127,24 @@ class TranscriptionStreamReceiver implements MessageReceiver { return; } _controllerClosed = true; - _controller?.close(); + final controller = _controller; _controller = null; + if (controller != null) { + unawaited(controller.close()); + } } ReceivedMessage _processIncoming( String chunk, - TextStreamReader reader, + TextStreamInfo info, String participantIdentity, ) { - final attributes = _TranscriptionAttributes.from(reader.info.attributes); - final segmentId = _extractSegmentId(reader.info.attributes, reader.info.id); + final attributes = _TranscriptionAttributes.from(info.attributes); + final segmentId = _extractSegmentId(info.attributes, info.id); final key = _PartialMessageId(segmentId: segmentId, participantId: participantIdentity); - final currentStreamId = reader.info.id; + final currentStreamId = info.id; - final DateTime timestamp = - DateTime.fromMillisecondsSinceEpoch(reader.info.timestamp, isUtc: true).toLocal(); + final DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(info.timestamp, isUtc: true).toLocal(); final existing = _partialMessages[key]; if (existing != null) { @@ -197,9 +206,12 @@ class TranscriptionStreamReceiver implements MessageReceiver { _partialMessages.clear(); if (!_controllerClosed) { _controllerClosed = true; - await _controller?.close(); + final controller = _controller; + _controller = null; + if (controller != null) { + await controller.close(); + } } - _controller = null; } } @@ -214,9 +226,7 @@ class _PartialMessageId { @override bool operator ==(Object other) => - other is _PartialMessageId && - other.segmentId == segmentId && - other.participantId == participantId; + other is _PartialMessageId && other.segmentId == segmentId && other.participantId == participantId; @override int get hashCode => Object.hash(segmentId, participantId); diff --git a/lib/src/agent/constants.dart b/lib/src/agent/constants.dart new file mode 100644 index 00000000..b8beb871 --- /dev/null +++ b/lib/src/agent/constants.dart @@ -0,0 +1,18 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Participant attribute keys used for agent metadata. +const lkAgentStateAttributeKey = 'lk.agent.state'; +const lkPublishOnBehalfAttributeKey = 'lk.publish_on_behalf'; +const lkAgentNameAttributeKey = 'lk.agent_name'; diff --git a/lib/src/agent/room_agent.dart b/lib/src/agent/room_agent.dart index a9162c91..f4d5473f 100644 --- a/lib/src/agent/room_agent.dart +++ b/lib/src/agent/room_agent.dart @@ -17,6 +17,7 @@ import 'package:collection/collection.dart'; import '../core/room.dart'; import '../participant/remote.dart'; import '../types/other.dart'; +import 'constants.dart'; extension AgentRoom on Room { /// All agent participants currently in the room. @@ -25,7 +26,7 @@ extension AgentRoom on Room { if (participant.kind != ParticipantKind.AGENT) { return false; } - final publishOnBehalf = participant.attributes['lk.publish_on_behalf']; + final publishOnBehalf = participant.attributes[lkPublishOnBehalfAttributeKey]; return publishOnBehalf == null || publishOnBehalf.isEmpty; }, ); diff --git a/lib/src/agent/session.dart b/lib/src/agent/session.dart index 44ea93cf..192e7023 100644 --- a/lib/src/agent/session.dart +++ b/lib/src/agent/session.dart @@ -75,7 +75,7 @@ class Session extends DisposableChangeNotifier { onDispose(() async { _agent.removeListener(notifyListeners); - _roomListener?.dispose(); + await _roomListener?.dispose(); await _cancelReceiverSubscriptions(); await Future.wait(_receivers.toSet().map((receiver) => receiver.dispose())); _agentTimeoutTimer?.cancel(); diff --git a/lib/src/types/attribute_typings.dart b/lib/src/types/attribute_typings.dart index b389f055..2c79a467 100644 --- a/lib/src/types/attribute_typings.dart +++ b/lib/src/types/attribute_typings.dart @@ -1,109 +1,172 @@ -// To parse this JSON data, do -// -// final agentAttributes = agentAttributesFromJson(jsonString); -// final transcriptionAttributes = transcriptionAttributesFromJson(jsonString); - import 'dart:convert'; -AgentAttributes agentAttributesFromJson(String str) => AgentAttributes.fromJson(json.decode(str)); +import '../agent/constants.dart'; + +AgentAttributes agentAttributesFromJson(String str) => + AgentAttributes.fromJson(json.decode(str) as Map); String agentAttributesToJson(AgentAttributes data) => json.encode(data.toJson()); TranscriptionAttributes transcriptionAttributesFromJson(String str) => - TranscriptionAttributes.fromJson(json.decode(str)); + TranscriptionAttributes.fromJson(json.decode(str) as Map); String transcriptionAttributesToJson(TranscriptionAttributes data) => json.encode(data.toJson()); class AgentAttributes { - List? lkAgentInputs; - List? lkAgentOutputs; - AgentState? lkAgentState; - String? lkPublishOnBehalf; - - AgentAttributes({ + const AgentAttributes({ this.lkAgentInputs, this.lkAgentOutputs, this.lkAgentState, this.lkPublishOnBehalf, }); + final List? lkAgentInputs; + final List? lkAgentOutputs; + final AgentState? lkAgentState; + final String? lkPublishOnBehalf; + factory AgentAttributes.fromJson(Map json) => AgentAttributes( - lkAgentInputs: json['lk.agent.inputs'] == null - ? [] - : List.from(json['lk.agent.inputs']!.map((x) => agentInputValues.map[x]!)), - lkAgentOutputs: json['lk.agent.outputs'] == null - ? [] - : List.from(json['lk.agent.outputs']!.map((x) => agentOutputValues.map[x]!)), - lkAgentState: agentStateValues.map[json['lk.agent.state']]!, - lkPublishOnBehalf: json['lk.publish_on_behalf'], + lkAgentInputs: _decodeAgentInputList(json[_AgentKeys.inputs]), + lkAgentOutputs: _decodeAgentOutputList(json[_AgentKeys.outputs]), + lkAgentState: _decodeAgentState(json[lkAgentStateAttributeKey]), + lkPublishOnBehalf: json[lkPublishOnBehalfAttributeKey] as String?, ); Map toJson() => { - 'lk.agent.inputs': - lkAgentInputs == null ? [] : List.from(lkAgentInputs!.map((x) => agentInputValues.reverse[x])), - 'lk.agent.outputs': - lkAgentOutputs == null ? [] : List.from(lkAgentOutputs!.map((x) => agentOutputValues.reverse[x])), - 'lk.agent.state': agentStateValues.reverse[lkAgentState], - 'lk.publish_on_behalf': lkPublishOnBehalf, + if (lkAgentInputs != null && lkAgentInputs!.isNotEmpty) + _AgentKeys.inputs: lkAgentInputs!.map(_agentInputToJson).toList(), + if (lkAgentOutputs != null && lkAgentOutputs!.isNotEmpty) + _AgentKeys.outputs: lkAgentOutputs!.map(_agentOutputToJson).toList(), + if (lkAgentState != null) lkAgentStateAttributeKey: _agentStateToJson(lkAgentState!), + if (lkPublishOnBehalf != null) lkPublishOnBehalfAttributeKey: lkPublishOnBehalf, }; } enum AgentInput { AUDIO, TEXT, VIDEO } -final agentInputValues = EnumValues({'audio': AgentInput.AUDIO, 'text': AgentInput.TEXT, 'video': AgentInput.VIDEO}); - enum AgentOutput { AUDIO, TRANSCRIPTION } -final agentOutputValues = EnumValues({'audio': AgentOutput.AUDIO, 'transcription': AgentOutput.TRANSCRIPTION}); - enum AgentState { IDLE, INITIALIZING, LISTENING, SPEAKING, THINKING } -final agentStateValues = EnumValues({ - 'idle': AgentState.IDLE, - 'initializing': AgentState.INITIALIZING, - 'listening': AgentState.LISTENING, - 'speaking': AgentState.SPEAKING, - 'thinking': AgentState.THINKING -}); +List? _decodeAgentInputList(Object? raw) { + final values = raw as List?; + if (values == null) { + return null; + } + return values + .map((value) => _agentInputFromJson(value as String?)) + .where((value) => value != null) + .cast() + .toList(); +} -///Schema for transcription-related attributes -class TranscriptionAttributes { - ///The segment id of the transcription - String? lkSegmentId; +List? _decodeAgentOutputList(Object? raw) { + final values = raw as List?; + if (values == null) { + return null; + } + return values + .map((value) => _agentOutputFromJson(value as String?)) + .where((value) => value != null) + .cast() + .toList(); +} - ///The associated track id of the transcription - String? lkTranscribedTrackId; +AgentState? _decodeAgentState(Object? raw) { + if (raw is! String) { + return null; + } + return _agentStateFromJson(raw); +} - ///Whether the transcription is final - bool? lkTranscriptionFinal; +AgentInput? _agentInputFromJson(String? value) { + return switch (value) { + 'audio' => AgentInput.AUDIO, + 'text' => AgentInput.TEXT, + 'video' => AgentInput.VIDEO, + _ => null, + }; +} + +String _agentInputToJson(AgentInput input) => switch (input) { + AgentInput.AUDIO => 'audio', + AgentInput.TEXT => 'text', + AgentInput.VIDEO => 'video', + }; + +AgentOutput? _agentOutputFromJson(String? value) { + return switch (value) { + 'audio' => AgentOutput.AUDIO, + 'transcription' => AgentOutput.TRANSCRIPTION, + _ => null, + }; +} + +String _agentOutputToJson(AgentOutput output) => switch (output) { + AgentOutput.AUDIO => 'audio', + AgentOutput.TRANSCRIPTION => 'transcription', + }; + +AgentState? _agentStateFromJson(String value) { + return switch (value) { + 'idle' => AgentState.IDLE, + 'initializing' => AgentState.INITIALIZING, + 'listening' => AgentState.LISTENING, + 'speaking' => AgentState.SPEAKING, + 'thinking' => AgentState.THINKING, + _ => null, + }; +} + +String _agentStateToJson(AgentState state) => switch (state) { + AgentState.IDLE => 'idle', + AgentState.INITIALIZING => 'initializing', + AgentState.LISTENING => 'listening', + AgentState.SPEAKING => 'speaking', + AgentState.THINKING => 'thinking', + }; + +class _AgentKeys { + static const inputs = 'lk.agent.inputs'; + static const outputs = 'lk.agent.outputs'; +} - TranscriptionAttributes({ +class TranscriptionAttributes { + const TranscriptionAttributes({ this.lkSegmentId, this.lkTranscribedTrackId, this.lkTranscriptionFinal, }); + final String? lkSegmentId; + final String? lkTranscribedTrackId; + final bool? lkTranscriptionFinal; + factory TranscriptionAttributes.fromJson(Map json) => TranscriptionAttributes( - lkSegmentId: json['lk.segment_id'], - lkTranscribedTrackId: json['lk.transcribed_track_id'], - lkTranscriptionFinal: json['lk.transcription_final'], + lkSegmentId: json['lk.segment_id'] as String?, + lkTranscribedTrackId: json['lk.transcribed_track_id'] as String?, + lkTranscriptionFinal: _parseBool(json['lk.transcription_final']), ); Map toJson() => { - 'lk.segment_id': lkSegmentId, - 'lk.transcribed_track_id': lkTranscribedTrackId, - 'lk.transcription_final': lkTranscriptionFinal, + if (lkSegmentId != null) 'lk.segment_id': lkSegmentId, + if (lkTranscribedTrackId != null) 'lk.transcribed_track_id': lkTranscribedTrackId, + if (lkTranscriptionFinal != null) 'lk.transcription_final': lkTranscriptionFinal, }; } -class EnumValues { - Map map; - late Map reverseMap; - - EnumValues(this.map); - - Map get reverse { - reverseMap = map.map((k, v) => MapEntry(v, k)); - return reverseMap; +bool? _parseBool(Object? value) { + if (value is bool) { + return value; + } + if (value is String) { + final normalized = value.toLowerCase(); + if (normalized == 'true' || normalized == '1') { + return true; + } + if (normalized == 'false' || normalized == '0') { + return false; + } } + return null; } From d27f6291096efa2824383314ec2482d8c5674abf Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:55:08 +0700 Subject: [PATCH 12/19] json serializable 1 --- build.yaml | 7 ++ lib/src/json/agent_attributes.dart | 116 +++++++++++++++++++++ lib/src/json/agent_attributes.g.dart | 66 ++++++++++++ pubspec.lock | 144 +++++++++++++++++++++++++++ pubspec.yaml | 3 + 5 files changed, 336 insertions(+) create mode 100644 build.yaml create mode 100644 lib/src/json/agent_attributes.dart create mode 100644 lib/src/json/agent_attributes.g.dart diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..ab338fde --- /dev/null +++ b/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + json_serializable: + generate_for: + include: + - lib/src/json/**.dart diff --git a/lib/src/json/agent_attributes.dart b/lib/src/json/agent_attributes.dart new file mode 100644 index 00000000..3eb5f7b9 --- /dev/null +++ b/lib/src/json/agent_attributes.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +import '../agent/constants.dart'; + +part 'agent_attributes.g.dart'; + +AgentAttributes agentAttributesFromJson(String source) => + AgentAttributes.fromJson(jsonDecode(source) as Map); + +String agentAttributesToJson(AgentAttributes value) => jsonEncode(value.toJson()); + +TranscriptionAttributes transcriptionAttributesFromJson(String source) => + TranscriptionAttributes.fromJson(jsonDecode(source) as Map); + +String transcriptionAttributesToJson(TranscriptionAttributes value) => jsonEncode(value.toJson()); + +@JsonSerializable() +class AgentAttributes { + const AgentAttributes({ + this.lkAgentInputs, + this.lkAgentOutputs, + this.lkAgentState, + this.lkPublishOnBehalf, + }); + + @JsonKey(name: lkAgentInputsAttributeKey) + final List? lkAgentInputs; + + @JsonKey(name: lkAgentOutputsAttributeKey) + final List? lkAgentOutputs; + + @JsonKey(name: lkAgentStateAttributeKey) + final AgentState? lkAgentState; + + @JsonKey(name: lkPublishOnBehalfAttributeKey) + final String? lkPublishOnBehalf; + + factory AgentAttributes.fromJson(Map json) => _$AgentAttributesFromJson(json); + + Map toJson() => _$AgentAttributesToJson(this); +} + +@JsonEnum(alwaysCreate: true) +enum AgentInput { + @JsonValue('audio') + AUDIO, + @JsonValue('text') + TEXT, + @JsonValue('video') + VIDEO, +} + +@JsonEnum(alwaysCreate: true) +enum AgentOutput { + @JsonValue('audio') + AUDIO, + @JsonValue('transcription') + TRANSCRIPTION, +} + +@JsonEnum(alwaysCreate: true) +enum AgentState { + @JsonValue('idle') + IDLE, + @JsonValue('initializing') + INITIALIZING, + @JsonValue('listening') + LISTENING, + @JsonValue('speaking') + SPEAKING, + @JsonValue('thinking') + THINKING, +} + +@JsonSerializable() +class TranscriptionAttributes { + const TranscriptionAttributes({ + this.lkSegmentId, + this.lkTranscribedTrackId, + this.lkTranscriptionFinal, + }); + + @JsonKey(name: 'lk.segment_id') + final String? lkSegmentId; + + @JsonKey(name: 'lk.transcribed_track_id') + final String? lkTranscribedTrackId; + + @JsonKey(name: 'lk.transcription_final', fromJson: _boolFromJson, toJson: _boolToJson) + final bool? lkTranscriptionFinal; + + factory TranscriptionAttributes.fromJson(Map json) => + _$TranscriptionAttributesFromJson(json); + + Map toJson() => _$TranscriptionAttributesToJson(this); +} + +bool? _boolFromJson(Object? value) { + if (value is bool) { + return value; + } + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true' || lower == '1') { + return true; + } + if (lower == 'false' || lower == '0') { + return false; + } + } + return null; +} + +Object? _boolToJson(bool? value) => value; diff --git a/lib/src/json/agent_attributes.g.dart b/lib/src/json/agent_attributes.g.dart new file mode 100644 index 00000000..314c1dc8 --- /dev/null +++ b/lib/src/json/agent_attributes.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'agent_attributes.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AgentAttributes _$AgentAttributesFromJson(Map json) => + AgentAttributes( + lkAgentInputs: (json['lkAgentInputs'] as List?) + ?.map((e) => $enumDecode(_$AgentInputEnumMap, e)) + .toList(), + lkAgentOutputs: (json['lkAgentOutputs'] as List?) + ?.map((e) => $enumDecode(_$AgentOutputEnumMap, e)) + .toList(), + lkAgentState: + $enumDecodeNullable(_$AgentStateEnumMap, json['lk.agent.state']), + lkPublishOnBehalf: json['lk.publish_on_behalf'] as String?, + ); + +Map _$AgentAttributesToJson(AgentAttributes instance) => + { + 'lkAgentInputs': + instance.lkAgentInputs?.map((e) => _$AgentInputEnumMap[e]!).toList(), + 'lkAgentOutputs': instance.lkAgentOutputs + ?.map((e) => _$AgentOutputEnumMap[e]!) + .toList(), + 'lk.agent.state': _$AgentStateEnumMap[instance.lkAgentState], + 'lk.publish_on_behalf': instance.lkPublishOnBehalf, + }; + +const _$AgentInputEnumMap = { + AgentInput.AUDIO: 'audio', + AgentInput.TEXT: 'text', + AgentInput.VIDEO: 'video', +}; + +const _$AgentOutputEnumMap = { + AgentOutput.AUDIO: 'audio', + AgentOutput.TRANSCRIPTION: 'transcription', +}; + +const _$AgentStateEnumMap = { + AgentState.IDLE: 'idle', + AgentState.INITIALIZING: 'initializing', + AgentState.LISTENING: 'listening', + AgentState.SPEAKING: 'speaking', + AgentState.THINKING: 'thinking', +}; + +TranscriptionAttributes _$TranscriptionAttributesFromJson( + Map json) => + TranscriptionAttributes( + lkSegmentId: json['lk.segment_id'] as String?, + lkTranscribedTrackId: json['lk.transcribed_track_id'] as String?, + lkTranscriptionFinal: _boolFromJson(json['lk.transcription_final']), + ); + +Map _$TranscriptionAttributesToJson( + TranscriptionAttributes instance) => + { + 'lk.segment_id': instance.lkSegmentId, + 'lk.transcribed_track_id': instance.lkTranscribedTrackId, + 'lk.transcription_final': _boolToJson(instance.lkTranscriptionFinal), + }; diff --git a/pubspec.lock b/pubspec.lock index b38eb7b1..912a460f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3 + url: "https://pub.dev" + source: hosted + version: "2.10.1" built_collection: dependency: transitive description: @@ -81,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -256,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: "direct main" description: @@ -264,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -280,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" js: dependency: transitive description: @@ -288,6 +344,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + url: "https://pub.dev" + source: hosted + version: "6.11.1" leak_tracker: dependency: transitive description: @@ -360,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime_type: dependency: "direct main" description: @@ -480,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" protobuf: dependency: "direct main" description: @@ -496,6 +584,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" sdp_transform: dependency: "direct main" description: @@ -504,6 +600,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -517,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + url: "https://pub.dev" + source: hosted + version: "1.3.8" source_span: dependency: transitive description: @@ -549,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -637,6 +765,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" webrtc_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c55990b8..1a9e9b64 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: mime_type: ^1.0.1 path: ^1.9.1 dart_jsonwebtoken: ^3.3.1 + json_annotation: ^4.9.0 # Fix version to avoid version conflicts between WebRTC-SDK pods, which both this package and flutter_webrtc depend on. flutter_webrtc: 1.2.0 @@ -55,6 +56,8 @@ dev_dependencies: mockito: ^5.3.2 import_sorter: ^4.6.0 yaml: ^3.1.2 + build_runner: ^2.4.13 + json_serializable: ^6.9.5 import_sorter: comments: false From 4f051bf25085520479e1850c545b7a71583a261b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:03:08 +0900 Subject: [PATCH 13/19] fixes --- build.yaml | 1 + lib/livekit_client.dart | 2 +- lib/src/agent/agent.dart | 2 +- lib/src/agent/constants.dart | 2 + lib/src/json/agent_attributes.dart | 3 +- lib/src/json/agent_attributes.g.dart | 46 +++++------ lib/src/types/attribute_typings.dart | 103 ------------------------- lib/src/types/attribute_typings.g.dart | 56 -------------- 8 files changed, 24 insertions(+), 191 deletions(-) delete mode 100644 lib/src/types/attribute_typings.dart delete mode 100644 lib/src/types/attribute_typings.g.dart diff --git a/build.yaml b/build.yaml index 69ef5a31..280d04e1 100644 --- a/build.yaml +++ b/build.yaml @@ -22,6 +22,7 @@ targets: generate_for: include: - lib/src/json/**.dart + - lib/src/token_source/**.dart options: include_if_null: false explicit_to_json: true diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index b835f74e..a0f6b21c 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -57,7 +57,7 @@ export 'src/track/remote/audio.dart'; export 'src/track/remote/remote.dart'; export 'src/track/remote/video.dart'; export 'src/track/track.dart'; -export 'src/types/attribute_typings.dart'; +export 'src/json/agent_attributes.dart'; export 'src/types/data_stream.dart'; export 'src/types/other.dart'; export 'src/types/participant_permissions.dart'; diff --git a/lib/src/agent/agent.dart b/lib/src/agent/agent.dart index 1aa4a6f7..a56ea3bf 100644 --- a/lib/src/agent/agent.dart +++ b/lib/src/agent/agent.dart @@ -16,11 +16,11 @@ import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; +import '../json/agent_attributes.dart'; import '../participant/participant.dart'; import '../participant/remote.dart'; import '../track/remote/audio.dart'; import '../track/remote/video.dart'; -import '../types/attribute_typings.dart'; import '../types/other.dart'; import 'constants.dart'; diff --git a/lib/src/agent/constants.dart b/lib/src/agent/constants.dart index b8beb871..2a6abc0a 100644 --- a/lib/src/agent/constants.dart +++ b/lib/src/agent/constants.dart @@ -16,3 +16,5 @@ const lkAgentStateAttributeKey = 'lk.agent.state'; const lkPublishOnBehalfAttributeKey = 'lk.publish_on_behalf'; const lkAgentNameAttributeKey = 'lk.agent_name'; +const lkAgentInputsAttributeKey = 'lk.agent.inputs'; +const lkAgentOutputsAttributeKey = 'lk.agent.outputs'; diff --git a/lib/src/json/agent_attributes.dart b/lib/src/json/agent_attributes.dart index 3eb5f7b9..2054fe55 100644 --- a/lib/src/json/agent_attributes.dart +++ b/lib/src/json/agent_attributes.dart @@ -91,8 +91,7 @@ class TranscriptionAttributes { @JsonKey(name: 'lk.transcription_final', fromJson: _boolFromJson, toJson: _boolToJson) final bool? lkTranscriptionFinal; - factory TranscriptionAttributes.fromJson(Map json) => - _$TranscriptionAttributesFromJson(json); + factory TranscriptionAttributes.fromJson(Map json) => _$TranscriptionAttributesFromJson(json); Map toJson() => _$TranscriptionAttributesToJson(this); } diff --git a/lib/src/json/agent_attributes.g.dart b/lib/src/json/agent_attributes.g.dart index 314c1dc8..d2bb0b85 100644 --- a/lib/src/json/agent_attributes.g.dart +++ b/lib/src/json/agent_attributes.g.dart @@ -6,28 +6,22 @@ part of 'agent_attributes.dart'; // JsonSerializableGenerator // ************************************************************************** -AgentAttributes _$AgentAttributesFromJson(Map json) => - AgentAttributes( - lkAgentInputs: (json['lkAgentInputs'] as List?) - ?.map((e) => $enumDecode(_$AgentInputEnumMap, e)) - .toList(), - lkAgentOutputs: (json['lkAgentOutputs'] as List?) - ?.map((e) => $enumDecode(_$AgentOutputEnumMap, e)) - .toList(), - lkAgentState: - $enumDecodeNullable(_$AgentStateEnumMap, json['lk.agent.state']), +AgentAttributes _$AgentAttributesFromJson(Map json) => AgentAttributes( + lkAgentInputs: + (json['lk.agent.inputs'] as List?)?.map((e) => $enumDecode(_$AgentInputEnumMap, e)).toList(), + lkAgentOutputs: + (json['lk.agent.outputs'] as List?)?.map((e) => $enumDecode(_$AgentOutputEnumMap, e)).toList(), + lkAgentState: $enumDecodeNullable(_$AgentStateEnumMap, json['lk.agent.state']), lkPublishOnBehalf: json['lk.publish_on_behalf'] as String?, ); -Map _$AgentAttributesToJson(AgentAttributes instance) => - { - 'lkAgentInputs': - instance.lkAgentInputs?.map((e) => _$AgentInputEnumMap[e]!).toList(), - 'lkAgentOutputs': instance.lkAgentOutputs - ?.map((e) => _$AgentOutputEnumMap[e]!) - .toList(), - 'lk.agent.state': _$AgentStateEnumMap[instance.lkAgentState], - 'lk.publish_on_behalf': instance.lkPublishOnBehalf, +Map _$AgentAttributesToJson(AgentAttributes instance) => { + if (instance.lkAgentInputs?.map((e) => _$AgentInputEnumMap[e]!).toList() case final value?) + 'lk.agent.inputs': value, + if (instance.lkAgentOutputs?.map((e) => _$AgentOutputEnumMap[e]!).toList() case final value?) + 'lk.agent.outputs': value, + if (_$AgentStateEnumMap[instance.lkAgentState] case final value?) 'lk.agent.state': value, + if (instance.lkPublishOnBehalf case final value?) 'lk.publish_on_behalf': value, }; const _$AgentInputEnumMap = { @@ -49,18 +43,14 @@ const _$AgentStateEnumMap = { AgentState.THINKING: 'thinking', }; -TranscriptionAttributes _$TranscriptionAttributesFromJson( - Map json) => - TranscriptionAttributes( +TranscriptionAttributes _$TranscriptionAttributesFromJson(Map json) => TranscriptionAttributes( lkSegmentId: json['lk.segment_id'] as String?, lkTranscribedTrackId: json['lk.transcribed_track_id'] as String?, lkTranscriptionFinal: _boolFromJson(json['lk.transcription_final']), ); -Map _$TranscriptionAttributesToJson( - TranscriptionAttributes instance) => - { - 'lk.segment_id': instance.lkSegmentId, - 'lk.transcribed_track_id': instance.lkTranscribedTrackId, - 'lk.transcription_final': _boolToJson(instance.lkTranscriptionFinal), +Map _$TranscriptionAttributesToJson(TranscriptionAttributes instance) => { + if (instance.lkSegmentId case final value?) 'lk.segment_id': value, + if (instance.lkTranscribedTrackId case final value?) 'lk.transcribed_track_id': value, + if (_boolToJson(instance.lkTranscriptionFinal) case final value?) 'lk.transcription_final': value, }; diff --git a/lib/src/types/attribute_typings.dart b/lib/src/types/attribute_typings.dart deleted file mode 100644 index 52cf502a..00000000 --- a/lib/src/types/attribute_typings.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:convert'; - -import 'package:json_annotation/json_annotation.dart'; - -import '../agent/constants.dart'; - -part 'attribute_typings.g.dart'; - -AgentAttributes agentAttributesFromJson(String str) => - AgentAttributes.fromJson(json.decode(str) as Map); - -String agentAttributesToJson(AgentAttributes data) => json.encode(data.toJson()); - -TranscriptionAttributes transcriptionAttributesFromJson(String str) => - TranscriptionAttributes.fromJson(json.decode(str) as Map); - -String transcriptionAttributesToJson(TranscriptionAttributes data) => json.encode(data.toJson()); - -@JsonSerializable() -class AgentAttributes { - @JsonKey(name: 'lk.agent.inputs') - final List? lkAgentInputs; - - @JsonKey(name: 'lk.agent.outputs') - final List? lkAgentOutputs; - - @JsonKey(name: lkAgentStateAttributeKey) - final AgentState? lkAgentState; - - @JsonKey(name: lkPublishOnBehalfAttributeKey) - final String? lkPublishOnBehalf; - - const AgentAttributes({ - this.lkAgentInputs, - this.lkAgentOutputs, - this.lkAgentState, - this.lkPublishOnBehalf, - }); - - factory AgentAttributes.fromJson(Map json) => - _$AgentAttributesFromJson(json); - - Map toJson() => _$AgentAttributesToJson(this); -} - -@JsonEnum() -enum AgentInput { - @JsonValue('audio') - audio, - @JsonValue('text') - text, - @JsonValue('video') - video, -} - -@JsonEnum() -enum AgentOutput { - @JsonValue('audio') - audio, - @JsonValue('transcription') - transcription, -} - -@JsonEnum() -enum AgentState { - @JsonValue('idle') - idle, - @JsonValue('initializing') - initializing, - @JsonValue('listening') - listening, - @JsonValue('speaking') - speaking, - @JsonValue('thinking') - thinking, -} - -/// Schema for transcription-related attributes -@JsonSerializable() -class TranscriptionAttributes { - /// The segment id of the transcription - @JsonKey(name: 'lk.segment_id') - final String? lkSegmentId; - - /// The associated track id of the transcription - @JsonKey(name: 'lk.transcribed_track_id') - final String? lkTranscribedTrackId; - - /// Whether the transcription is final - @JsonKey(name: 'lk.transcription_final') - final bool? lkTranscriptionFinal; - - const TranscriptionAttributes({ - this.lkSegmentId, - this.lkTranscribedTrackId, - this.lkTranscriptionFinal, - }); - - factory TranscriptionAttributes.fromJson(Map json) => - _$TranscriptionAttributesFromJson(json); - - Map toJson() => _$TranscriptionAttributesToJson(this); -} diff --git a/lib/src/types/attribute_typings.g.dart b/lib/src/types/attribute_typings.g.dart deleted file mode 100644 index 05814fe1..00000000 --- a/lib/src/types/attribute_typings.g.dart +++ /dev/null @@ -1,56 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'attribute_typings.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -AgentAttributes _$AgentAttributesFromJson(Map json) => AgentAttributes( - lkAgentInputs: - (json['lk.agent.inputs'] as List?)?.map((e) => $enumDecode(_$AgentInputEnumMap, e)).toList(), - lkAgentOutputs: - (json['lk.agent.outputs'] as List?)?.map((e) => $enumDecode(_$AgentOutputEnumMap, e)).toList(), - lkAgentState: $enumDecodeNullable(_$AgentStateEnumMap, json['lk.agent.state']), - lkPublishOnBehalf: json['lk.publish_on_behalf'] as String?, - ); - -Map _$AgentAttributesToJson(AgentAttributes instance) => { - if (instance.lkAgentInputs?.map((e) => _$AgentInputEnumMap[e]!).toList() case final value?) - 'lk.agent.inputs': value, - if (instance.lkAgentOutputs?.map((e) => _$AgentOutputEnumMap[e]!).toList() case final value?) - 'lk.agent.outputs': value, - if (_$AgentStateEnumMap[instance.lkAgentState] case final value?) 'lk.agent.state': value, - if (instance.lkPublishOnBehalf case final value?) 'lk.publish_on_behalf': value, - }; - -const _$AgentInputEnumMap = { - AgentInput.audio: 'audio', - AgentInput.text: 'text', - AgentInput.video: 'video', -}; - -const _$AgentOutputEnumMap = { - AgentOutput.audio: 'audio', - AgentOutput.transcription: 'transcription', -}; - -const _$AgentStateEnumMap = { - AgentState.idle: 'idle', - AgentState.initializing: 'initializing', - AgentState.listening: 'listening', - AgentState.speaking: 'speaking', - AgentState.thinking: 'thinking', -}; - -TranscriptionAttributes _$TranscriptionAttributesFromJson(Map json) => TranscriptionAttributes( - lkSegmentId: json['lk.segment_id'] as String?, - lkTranscribedTrackId: json['lk.transcribed_track_id'] as String?, - lkTranscriptionFinal: json['lk.transcription_final'] as bool?, - ); - -Map _$TranscriptionAttributesToJson(TranscriptionAttributes instance) => { - if (instance.lkSegmentId case final value?) 'lk.segment_id': value, - if (instance.lkTranscribedTrackId case final value?) 'lk.transcribed_track_id': value, - if (instance.lkTranscriptionFinal case final value?) 'lk.transcription_final': value, - }; From 11504b82c15779c9b21ce0ad9f069a20e4b2a454 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 14 Nov 2025 02:42:16 +0900 Subject: [PATCH 14/19] adjustments --- lib/src/agent/agent.dart | 54 ++++++++++++++++++++++++++----- lib/src/agent/session.dart | 51 +++++++++++++++++------------ lib/src/token_source/jwt.dart | 19 +++++++++++ test/token/token_source_test.dart | 49 ++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 29 deletions(-) diff --git a/lib/src/agent/agent.dart b/lib/src/agent/agent.dart index a56ea3bf..4095628d 100644 --- a/lib/src/agent/agent.dart +++ b/lib/src/agent/agent.dart @@ -55,17 +55,51 @@ class Agent extends ChangeNotifier { RemoteVideoTrack? get avatarVideoTrack => _avatarVideoTrack; RemoteVideoTrack? _avatarVideoTrack; - /// Indicates whether the agent is connected. - bool get isConnected => switch (_state) { - _AgentLifecycle.connected => true, - _AgentLifecycle.connecting => false, - _AgentLifecycle.disconnected => false, - _AgentLifecycle.failed => false, - }; + /// Indicates whether the agent is connected and ready for conversation. + bool get isConnected { + if (_state != _AgentLifecycle.connected) { + return false; + } + return switch (_agentState) { + AgentState.LISTENING || AgentState.THINKING || AgentState.SPEAKING => true, + _ => false, + }; + } /// Whether the agent is buffering audio prior to connecting. bool get isBuffering => _state == _AgentLifecycle.connecting && _isBuffering; + /// Whether the agent can currently listen for user input. + bool get canListen { + if (_state == _AgentLifecycle.connecting) { + return _isBuffering; + } + if (_state == _AgentLifecycle.connected) { + return switch (_agentState) { + AgentState.LISTENING || AgentState.THINKING || AgentState.SPEAKING => true, + _ => false, + }; + } + return false; + } + + /// Whether the agent is pending initialization. + bool get isPending { + if (_state == _AgentLifecycle.connecting) { + return !_isBuffering; + } + if (_state == _AgentLifecycle.connected) { + return switch (_agentState) { + AgentState.IDLE || AgentState.INITIALIZING => true, + _ => false, + }; + } + return false; + } + + /// Whether the agent finished or failed its session. + bool get isFinished => _state == _AgentLifecycle.disconnected || _state == _AgentLifecycle.failed; + _AgentLifecycle _state = _AgentLifecycle.disconnected; bool _isBuffering = false; @@ -179,11 +213,15 @@ class Agent extends ChangeNotifier { /// Describes why an [Agent] failed to connect. enum AgentFailure { /// The agent did not connect within the allotted timeout. - timeout; + timeout, + + /// The agent left the room unexpectedly. + left; /// A human-readable error message. String get message => switch (this) { AgentFailure.timeout => 'Agent did not connect', + AgentFailure.left => 'Agent left the room unexpectedly', }; } diff --git a/lib/src/agent/session.dart b/lib/src/agent/session.dart index 192e7023..3927b1a0 100644 --- a/lib/src/agent/session.dart +++ b/lib/src/agent/session.dart @@ -25,6 +25,7 @@ import '../logger.dart'; import '../managers/event.dart'; import '../participant/remote.dart'; import '../support/disposable.dart'; +import '../token_source/jwt.dart'; import '../token_source/token_source.dart'; import '../types/other.dart'; import 'agent.dart'; @@ -177,36 +178,43 @@ class Session extends DisposableChangeNotifier { final Duration timeout = _options.agentConnectTimeout; - Future connect() async { + Future connect() async { final response = await _tokenSourceConfiguration.fetch(); await room.connect( response.serverUrl, response.participantToken, ); + return response.dispatchesAgent(); } try { + final bool dispatchesAgent; if (_options.preConnectAudio) { - await room.withPreConnectAudio( + dispatchesAgent = await room.withPreConnectAudio( () async { _setConnectionState(ConnectionState.connecting); _agent.connecting(buffering: true); - await connect(); + return connect(); }, timeout: timeout, ); } else { _setConnectionState(ConnectionState.connecting); _agent.connecting(buffering: false); - await connect(); + dispatchesAgent = await connect(); await room.localParticipant?.setMicrophoneEnabled(true); } - _agentTimeoutTimer = Timer(timeout, () { - if (isConnected && !_agent.isConnected) { - _agent.failed(AgentFailure.timeout); - } - }); + if (dispatchesAgent) { + _agentTimeoutTimer = Timer(timeout, () { + if (isConnected && !_agent.isConnected) { + _agent.failed(AgentFailure.timeout); + } + }); + } else { + _agentTimeoutTimer?.cancel(); + _agentTimeoutTimer = null; + } } catch (error, stackTrace) { logger.warning('Session.start() failed: $error', error, stackTrace); _setError(SessionError.connection(error)); @@ -300,27 +308,26 @@ class Session extends DisposableChangeNotifier { } void _handleRoomEvent(RoomEvent event) { + bool shouldUpdateAgent = false; + if (event is RoomConnectedEvent || event is RoomReconnectedEvent) { _setConnectionState(ConnectionState.connected); + shouldUpdateAgent = true; } else if (event is RoomReconnectingEvent) { _setConnectionState(ConnectionState.reconnecting); + shouldUpdateAgent = true; } else if (event is RoomDisconnectedEvent) { _setConnectionState(ConnectionState.disconnected); _agent.disconnected(); + shouldUpdateAgent = true; + } + + if (event is ParticipantEvent) { + shouldUpdateAgent = true; } - switch (event) { - case ParticipantConnectedEvent _: - case ParticipantDisconnectedEvent _: - case ParticipantAttributesChanged _: - case TrackPublishedEvent _: - case TrackUnpublishedEvent _: - case RoomConnectedEvent _: - case RoomReconnectedEvent _: - case RoomReconnectingEvent _: - _updateAgent(); - default: - break; + if (shouldUpdateAgent) { + _updateAgent(); } } @@ -336,6 +343,8 @@ class Session extends DisposableChangeNotifier { final RemoteParticipant? firstAgent = room.agentParticipants.firstOrNull; if (firstAgent != null) { _agent.connected(firstAgent); + } else if (_agent.isConnected) { + _agent.failed(AgentFailure.left); } else { _agent.connecting(buffering: _options.preConnectAudio); } diff --git a/lib/src/token_source/jwt.dart b/lib/src/token_source/jwt.dart index 35962987..a2ef8b2f 100644 --- a/lib/src/token_source/jwt.dart +++ b/lib/src/token_source/jwt.dart @@ -15,6 +15,7 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'room_configuration.dart'; import 'token_source.dart'; part 'jwt.g.dart'; @@ -75,6 +76,18 @@ class LiveKitJwtPayload { return null; } + /// Room configuration embedded in the token, if present. + RoomConfiguration? get roomConfiguration { + final raw = _claims['roomConfig'] ?? _claims['room_config']; + if (raw is Map) { + return RoomConfiguration.fromJson(Map.from(raw)); + } + if (raw is Map) { + return RoomConfiguration.fromJson(Map.from(raw)); + } + return null; + } + /// Token expiration instant in UTC. DateTime? get expiresAt => _dateTimeFor('exp'); @@ -203,6 +216,12 @@ extension TokenSourceJwt on TokenSourceResponse { return true; } + + /// Returns `true` when the token's room configuration dispatches at least one agent. + bool dispatchesAgent() { + final agents = jwtPayload?.roomConfiguration?.agents; + return agents != null && agents.isNotEmpty; + } } /// Extension to extract LiveKit-specific claims from JWT tokens. diff --git a/test/token/token_source_test.dart b/test/token/token_source_test.dart index 40f684d1..1fd620a6 100644 --- a/test/token/token_source_test.dart +++ b/test/token/token_source_test.dart @@ -174,6 +174,14 @@ void main() { 'hidden': false, 'recorder': true, }, + roomConfig: { + 'agents': [ + { + 'agent_name': 'demo-agent', + 'metadata': '{"foo":"bar"}', + } + ] + }, ); final response = TokenSourceResponse( @@ -201,6 +209,42 @@ void main() { expect(grant.canPublishSources, ['camera', 'screen']); expect(grant.hidden, isFalse); expect(grant.recorder, isTrue); + + final config = payload.roomConfiguration; + expect(config, isNotNull); + expect(config!.agents, isNotNull); + expect(config.agents, hasLength(1)); + expect(config.agents!.first.agentName, 'demo-agent'); + expect(config.agents!.first.metadata, '{"foo":"bar"}'); + }); + }); + + group('TokenSourceResponse', () { + test('dispatchesAgent returns true when JWT config includes agents', () { + final token = _generateToken( + roomConfig: { + 'agents': [ + {'agent_name': 'assistant'} + ] + }, + ); + + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.dispatchesAgent(), isTrue); + }); + + test('dispatchesAgent returns false when JWT lacks agents', () { + final token = _generateToken(); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.dispatchesAgent(), isFalse); }); }); @@ -445,6 +489,7 @@ String _generateToken({ String? metadata, Map? attributes, Map? video, + Map? roomConfig, }) { final payload = { 'sub': subject ?? 'test-participant', @@ -483,6 +528,10 @@ String _generateToken({ payload['attributes'] = Map.from(attributes); } + if (roomConfig != null) { + payload['roomConfig'] = roomConfig; + } + final jwt = JWT(payload); return jwt.sign(SecretKey('test-secret')); } From 412d0ac71aeacd3c9d09a1488c90c4ac4d876815 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:03:37 +0900 Subject: [PATCH 15/19] Fix missing pre-connect error handler --- lib/src/core/room_preconnect.dart | 1 + lib/src/preconnect/pre_connect_audio_buffer.dart | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/core/room_preconnect.dart b/lib/src/core/room_preconnect.dart index 8876d530..fa50b567 100644 --- a/lib/src/core/room_preconnect.dart +++ b/lib/src/core/room_preconnect.dart @@ -26,6 +26,7 @@ extension RoomPreConnect on Room { Duration timeout = const Duration(seconds: 10), PreConnectOnError? onError, }) async { + preConnectAudioBuffer.setErrorHandler(onError); await preConnectAudioBuffer.startRecording(timeout: timeout); try { final result = await operation(); diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index 03b79b42..c46b6fec 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -53,7 +53,7 @@ class PreConnectAudioBuffer { EventChannel? _eventChannel; StreamSubscription? _streamSubscription; - final PreConnectOnError? _onError; + PreConnectOnError? _onError; final int _requestSampleRate; int? _renderedSampleRate; @@ -301,4 +301,9 @@ class PreConnectAudioBuffer { logger.info( '[Preconnect audio] sent ${(data.length / 1024).toStringAsFixed(1)}KB of audio (${secondsOfAudio.toStringAsFixed(2)} seconds) to ${agents} agent(s)'); } + + /// Updates the callback invoked when pre-connect audio fails. + void setErrorHandler(PreConnectOnError? onError) { + _onError = onError; + } } From da81b4bbfffdb9e95fb0b2beca9025022438c9a3 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:56:57 +0900 Subject: [PATCH 16/19] changes file --- .changes/session-api | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/session-api diff --git a/.changes/session-api b/.changes/session-api new file mode 100644 index 00000000..83b90221 --- /dev/null +++ b/.changes/session-api @@ -0,0 +1 @@ +patch type="added" "Session API" From 9efa704627e061b9a5f0747111be8d347a060b37 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 2 Dec 2025 04:40:51 +0900 Subject: [PATCH 17/19] unwaited --- lib/src/core/room_preconnect.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/src/core/room_preconnect.dart b/lib/src/core/room_preconnect.dart index fa50b567..1b8452ff 100644 --- a/lib/src/core/room_preconnect.dart +++ b/lib/src/core/room_preconnect.dart @@ -30,14 +30,21 @@ extension RoomPreConnect on Room { await preConnectAudioBuffer.startRecording(timeout: timeout); try { final result = await operation(); - await preConnectAudioBuffer.agentReadyFuture; + unawaited(() async { + try { + await preConnectAudioBuffer.agentReadyFuture; + } catch (error, stackTrace) { + logger.warning('[Preconnect] agent readiness wait failed: $error', error, stackTrace); + } finally { + await preConnectAudioBuffer.reset(); + } + }()); return result; } catch (error) { await preConnectAudioBuffer.stopRecording(withError: error); + await preConnectAudioBuffer.reset(); logger.warning('[Preconnect] operation failed with error: $error'); rethrow; - } finally { - await preConnectAudioBuffer.reset(); } } } From c696b3f54d20618603e2be999d0de755d002fe0d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:58:41 +0900 Subject: [PATCH 18/19] test --- .../chat/transcription_stream_receiver.dart | 16 +- .../transcription_stream_receiver_test.dart | 192 ++++++++++++++++++ 2 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 test/agent/transcription_stream_receiver_test.dart diff --git a/lib/src/agent/chat/transcription_stream_receiver.dart b/lib/src/agent/chat/transcription_stream_receiver.dart index 0c4a6011..8bfc54c4 100644 --- a/lib/src/agent/chat/transcription_stream_receiver.dart +++ b/lib/src/agent/chat/transcription_stream_receiver.dart @@ -33,10 +33,16 @@ class TranscriptionStreamReceiver implements MessageReceiver { TranscriptionStreamReceiver({ required Room room, this.topic = 'lk.transcription', - }) : _room = room; + void Function(String topic, TextStreamHandler handler)? registerHandler, + void Function(String topic)? unregisterHandler, + }) : _room = room, + _registerHandler = registerHandler ?? room.registerTextStreamHandler, + _unregisterHandler = unregisterHandler ?? room.unregisterTextStreamHandler; final Room _room; final String topic; + final void Function(String, TextStreamHandler) _registerHandler; + final void Function(String) _unregisterHandler; StreamController? _controller; bool _registered = false; @@ -51,20 +57,20 @@ class TranscriptionStreamReceiver implements MessageReceiver { } _controller = StreamController.broadcast( - onListen: _registerHandler, + onListen: _registerRoomHandler, onCancel: _handleCancel, ); _controllerClosed = false; return _controller!.stream; } - void _registerHandler() { + void _registerRoomHandler() { if (_registered) { return; } _registered = true; - _room.registerTextStreamHandler(topic, (TextStreamReader reader, String participantIdentity) { + _registerHandler(topic, (TextStreamReader reader, String participantIdentity) { reader.listen( (chunk) { final info = reader.info; @@ -119,7 +125,7 @@ class TranscriptionStreamReceiver implements MessageReceiver { void _handleCancel() { if (_registered) { - _room.unregisterTextStreamHandler(topic); + _unregisterHandler(topic); _registered = false; } _partialMessages.clear(); diff --git a/test/agent/transcription_stream_receiver_test.dart b/test/agent/transcription_stream_receiver_test.dart new file mode 100644 index 00000000..99437a74 --- /dev/null +++ b/test/agent/transcription_stream_receiver_test.dart @@ -0,0 +1,192 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:fixnum/fixnum.dart' as fixnum; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models; + +class _StubLocalParticipant extends Fake implements LocalParticipant { + _StubLocalParticipant(this.identity); + + @override + final String identity; +} + +class _StubRoom extends Fake implements Room { + _StubRoom({required String localIdentity}) : localParticipant = _StubLocalParticipant(localIdentity); + + final Map _handlers = {}; + + @override + final LocalParticipant? localParticipant; + + void register(String topic, TextStreamHandler callback) { + _handlers[topic] = callback; + } + + void unregister(String topic) { + _handlers.remove(topic); + } + + @override + Map get textStreamHandlers => _handlers; +} + +lk_models.DataStream_Chunk _chunk(String streamId, String text) { + return lk_models.DataStream_Chunk( + streamId: streamId, + chunkIndex: fixnum.Int64(0), + content: utf8.encode(text), + version: 0, + ); +} + +TextStreamReader _reader({ + required String streamId, + required String segmentId, + required bool isFinal, + required String participantIdentity, +}) { + final info = TextStreamInfo( + id: streamId, + mimeType: 'text/plain', + topic: 'lk.transcription', + timestamp: DateTime.timestamp().millisecondsSinceEpoch, + size: 0, + attributes: { + 'lk.segment_id': segmentId, + 'lk.transcription_final': isFinal ? 'true' : 'false', + }, + sendingParticipantIdentity: participantIdentity, + ); + + final controller = DataStreamController( + info: info, + streamController: StreamController(), + startTime: DateTime.timestamp().millisecondsSinceEpoch, + ); + + return TextStreamReader(info, controller, null); +} + +TextStreamReader _readerWithoutSegmentId({ + required String streamId, + required bool isFinal, + required String participantIdentity, +}) { + final info = TextStreamInfo( + id: streamId, + mimeType: 'text/plain', + topic: 'lk.transcription', + timestamp: DateTime.timestamp().millisecondsSinceEpoch, + size: 0, + attributes: { + 'lk.transcription_final': isFinal ? 'true' : 'false', + }, + sendingParticipantIdentity: participantIdentity, + ); + + final controller = DataStreamController( + info: info, + streamController: StreamController(), + startTime: DateTime.timestamp().millisecondsSinceEpoch, + ); + + return TextStreamReader(info, controller, null); +} + +void main() { + group('TranscriptionStreamReceiver', () { + test('aggregates agent and user transcripts with segment replacement', () async { + final room = _StubRoom(localIdentity: 'local'); + final receiver = TranscriptionStreamReceiver( + room: room, + registerHandler: room.register, + unregisterHandler: room.unregister, + ); + + final messages = []; + final subscription = receiver.messages().listen(messages.add); + + final handler = room.textStreamHandlers['lk.transcription']; + expect(handler, isNotNull); + + // Agent sends partial, then final replacement for same segment. + final agentReader1 = _reader(streamId: 's1', segmentId: 'seg-1', isFinal: false, participantIdentity: 'agent-1'); + handler!(agentReader1, 'agent-1'); + agentReader1.reader!.write(_chunk('s1', 'Hello')); + + final agentReader2 = _reader(streamId: 's2', segmentId: 'seg-1', isFinal: true, participantIdentity: 'agent-1'); + handler(agentReader2, 'agent-1'); + agentReader2.reader!.write(_chunk('s2', 'Hello world')); + + // Local participant transcript should be classified as user transcript. + final userReader = _reader(streamId: 's3', segmentId: 'seg-2', isFinal: true, participantIdentity: 'local'); + handler(userReader, 'local'); + userReader.reader!.write(_chunk('s3', 'User said hi')); + + // Allow stream microtasks to flush. + await Future.delayed(Duration.zero); + + await subscription.cancel(); + + expect(messages.length, 3); + expect(messages[0].content, isA()); + expect((messages[0].content as AgentTranscript).text, 'Hello'); + + expect(messages[1].content, isA()); + expect((messages[1].content as AgentTranscript).text, 'Hello world'); + + expect(messages[2].content, isA()); + expect((messages[2].content as UserTranscript).text, 'User said hi'); + }); + + test('cleans up previous partials per participant and handles missing segment id', () async { + final room = _StubRoom(localIdentity: 'local'); + final receiver = TranscriptionStreamReceiver( + room: room, + registerHandler: room.register, + unregisterHandler: room.unregister, + ); + + final messages = []; + final errors = []; + final subscription = receiver.messages().listen(messages.add, onError: errors.add); + + final handler = room.textStreamHandlers['lk.transcription']; + expect(handler, isNotNull); + + // Participant A starts seg-A then seg-B; seg-A should be cleaned up once seg-B arrives. + final readerA1 = _reader(streamId: 'a1', segmentId: 'seg-A', isFinal: false, participantIdentity: 'agent-A'); + handler!(readerA1, 'agent-A'); + readerA1.reader!.write(_chunk('a1', 'Partial A')); + + final readerA2 = _reader(streamId: 'a2', segmentId: 'seg-B', isFinal: true, participantIdentity: 'agent-A'); + handler(readerA2, 'agent-A'); + readerA2.reader!.write(_chunk('a2', 'Final B')); + + // Participant B sends without segment id; fallback to stream id. + final readerB = _readerWithoutSegmentId(streamId: 'b1', isFinal: true, participantIdentity: 'agent-B'); + handler(readerB, 'agent-B'); + readerB.reader!.write(_chunk('b1', 'No segment id')); + + await Future.delayed(Duration.zero); + await subscription.cancel(); + + expect(errors, isEmpty); + expect(messages.length, 3); + expect(messages.where((m) => m.id == 'seg-A').length, 1); // received partial before cleanup + + // seg-B final, seg-A partial should have been removed. + expect(messages.where((m) => m.id == 'seg-B').length, 1); + final segB = messages.firstWhere((m) => m.id == 'seg-B'); + expect(segB.content, isA()); + expect((segB.content as AgentTranscript).text, 'Final B'); + + // Stream id used when segment id missing. + final b1 = messages.firstWhere((m) => m.id == 'b1'); + expect((b1.content as AgentTranscript).text, 'No segment id'); + }); + }); +} From 140a5e2ce3509a2086c827f223e2b1da62179243 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:48:54 +0900 Subject: [PATCH 19/19] fix --- test/agent/transcription_stream_receiver_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/agent/transcription_stream_receiver_test.dart b/test/agent/transcription_stream_receiver_test.dart index 99437a74..eaa0d157 100644 --- a/test/agent/transcription_stream_receiver_test.dart +++ b/test/agent/transcription_stream_receiver_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:fixnum/fixnum.dart' as fixnum; import 'package:flutter_test/flutter_test.dart'; + import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models;