diff --git a/melos.yaml b/melos.yaml index 10e7ca7b..fbbfcb78 100644 --- a/melos.yaml +++ b/melos.yaml @@ -54,11 +54,11 @@ scripts: description: Build all generated files for Dart & Flutter packages in this project. generate:dart: - run: melos exec -c 1 --depends-on="build_runner" --no-flutter -- "dart run build_runner build --delete-conflicting-outputs" + run: melos exec -c 1 --depends-on="build_runner" --no-flutter -- "dart run build_runner build --delete-conflicting-outputs --enable-experiment=super-parameters,enhanced-enums" description: Build all generated files for Dart packages in this project. generate:flutter: - run: melos exec -c 1 --depends-on="build_runner" --flutter -- "flutter pub run build_runner build --delete-conflicting-outputs" + run: melos exec -c 1 --depends-on="build_runner" --flutter -- "flutter run build_runner build --delete-conflicting-outputs --enable-experiment=super-parameters,enhanced-enums" description: Build all generated files for Flutter packages in this project. test:all: diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index c1809c1c..48446817 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming +✅ Added + +- Added `StreamChatClient.getCallToken` and `StreamChatClient.createCall` methods. + 🐞 Fixed - Only listen to client events when the user is connected to the websocket. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 79443394..66dd285a 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -592,6 +592,25 @@ class StreamChatClient { } } + /// Returns a token associated with the [callId]. + Future getCallToken(String callId) async => + _chatApi.call.getCallToken(callId); + + /// Creates a new call. + Future createCall({ + required String callId, + required String callType, + required String channelType, + required String channelId, + }) { + return _chatApi.call.createCall( + callId: callId, + callType: callType, + channelType: channelType, + channelId: channelId, + ); + } + /// Requests channels with a given query from the API. Future> queryChannelsOnline({ Filter? filter, diff --git a/packages/stream_chat/lib/src/core/api/call_api.dart b/packages/stream_chat/lib/src/core/api/call_api.dart new file mode 100644 index 00000000..2e40fc63 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/call_api.dart @@ -0,0 +1,41 @@ +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; + +/// Defines the api dedicated to call operations. +class CallApi { + /// Initialize a new call api + CallApi(this._client); + + final StreamHttpClient _client; + + /// Returns a token dedicated to the [callId] + Future getCallToken(String callId) async { + final response = await _client.post( + '/calls/$callId', + data: {}, + ); + // return response.data; + return CallTokenPayload.fromJson(response.data); + } + + /// Creates a new call + Future createCall({ + required String callId, + required String callType, + required String channelType, + required String channelId, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'id': callId, + 'type': callType, + }, + ); + // return response.data; + return CreateCallPayload.fromJson(response.data); + } + + String _getChannelUrl(String channelId, String channelType) => + '/channels/$channelType/$channelId/call'; +} diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index d608c5aa..54b885f4 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -1,7 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/api/call_api.dart'; import 'package:stream_chat/src/core/error/error.dart'; import 'package:stream_chat/src/core/models/banned_user.dart'; +import 'package:stream_chat/src/core/models/call_payload.dart'; import 'package:stream_chat/src/core/models/channel_model.dart'; import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/device.dart'; @@ -495,3 +497,31 @@ class OGAttachmentResponse extends _BaseResponse { static OGAttachmentResponse fromJson(Map json) => _$OGAttachmentResponseFromJson(json); } + +/// The response to [CallApi.getCallToken] +@JsonSerializable(createToJson: false) +class CallTokenPayload extends _BaseResponse { + /// Create a new instance from a [json]. + static CallTokenPayload fromJson(Map json) => + _$CallTokenPayloadFromJson(json); + + /// The token to use for the call. + String? token; + + /// The user id specific to Agora. + int? agoraUid; + + /// The appId specific to Agora. + String? agoraAppId; +} + +/// The response to [CallApi.createCall] +@JsonSerializable(createToJson: false) +class CreateCallPayload extends _BaseResponse { + /// Create a new instance from a [json]. + static CreateCallPayload fromJson(Map json) => + _$CreateCallPayloadFromJson(json); + + /// The call object. + CallPayload? call; +} diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 991b39f6..24f5c459 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -297,3 +297,17 @@ OGAttachmentResponse _$OGAttachmentResponseFromJson( ..title = json['title'] as String? ..titleLink = json['title_link'] as String? ..type = json['type'] as String?; + +CallTokenPayload _$CallTokenPayloadFromJson(Map json) => + CallTokenPayload() + ..duration = json['duration'] as String? + ..token = json['token'] as String? + ..agoraUid = json['agora_uid'] as int? + ..agoraAppId = json['agora_app_id'] as String?; + +CreateCallPayload _$CreateCallPayloadFromJson(Map json) => + CreateCallPayload() + ..duration = json['duration'] as String? + ..call = json['call'] == null + ? null + : CallPayload.fromJson(json['call'] as Map); diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart index cf6c0bad..b3f29210 100644 --- a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -1,5 +1,6 @@ import 'package:logging/logging.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/api/call_api.dart'; import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/device_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; @@ -53,6 +54,11 @@ class StreamChatApi { /// Api dedicated to message operations MessageApi get message => _message ??= MessageApi(_client); + CallApi? _call; + + /// Api dedicated to call operations + CallApi get call => _call ??= CallApi(_client); + ChannelApi? _channel; /// Api dedicated to channel operations diff --git a/packages/stream_chat/lib/src/core/models/call_payload.dart b/packages/stream_chat/lib/src/core/models/call_payload.dart new file mode 100644 index 00000000..e11ae788 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/call_payload.dart @@ -0,0 +1,72 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'call_payload.g.dart'; + +/// Model containing the information about a call. +@JsonSerializable(createToJson: false) +class CallPayload extends Equatable { + /// Create a new instance. + const CallPayload({ + required this.id, + required this.provider, + this.agora, + this.hms, + }); + + /// Create a new instance from a [json]. + factory CallPayload.fromJson(Map json) => + _$CallPayloadFromJson(json); + + /// The call id. + final String id; + + /// The call provider. + final String provider; + + /// The payload specific to Agora. + final AgoraPayload? agora; + + /// The payload specific to 100ms. + final HMSPayload? hms; + + @override + List get props => [id, provider, agora, hms]; +} + +/// Payload for Agora call. +@JsonSerializable(createToJson: false) +class AgoraPayload extends Equatable { + /// Create a new instance. + const AgoraPayload({required this.channel}); + + /// Create a new instance from a [json]. + factory AgoraPayload.fromJson(Map json) => + _$AgoraPayloadFromJson(json); + + /// The Agora channel. + final String channel; + + @override + List get props => [channel]; +} + +/// Payload for 100ms call. +@JsonSerializable(createToJson: false) +class HMSPayload extends Equatable { + /// Create a new instance. + const HMSPayload({required this.roomId, required this.roomName}); + + /// Create a new instance from a [json]. + factory HMSPayload.fromJson(Map json) => + _$HMSPayloadFromJson(json); + + /// The id of the 100ms room. + final String roomId; + + /// The name of the 100ms room. + final String roomName; + + @override + List get props => [roomId, roomName]; +} diff --git a/packages/stream_chat/lib/src/core/models/call_payload.g.dart b/packages/stream_chat/lib/src/core/models/call_payload.g.dart new file mode 100644 index 00000000..bc786cc5 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/call_payload.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'call_payload.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CallPayload _$CallPayloadFromJson(Map json) => CallPayload( + id: json['id'] as String, + provider: json['provider'] as String, + agora: json['agora'] == null + ? null + : AgoraPayload.fromJson(json['agora'] as Map), + hms: json['hms'] == null + ? null + : HMSPayload.fromJson(json['hms'] as Map), + ); + +AgoraPayload _$AgoraPayloadFromJson(Map json) => AgoraPayload( + channel: json['channel'] as String, + ); + +HMSPayload _$HMSPayloadFromJson(Map json) => HMSPayload( + roomId: json['room_id'] as String, + roomName: json['room_name'] as String, + ); diff --git a/packages/stream_chat/test/src/core/api/call_api_test.dart b/packages/stream_chat/test/src/core/api/call_api_test.dart new file mode 100644 index 00000000..f9e2265f --- /dev/null +++ b/packages/stream_chat/test/src/core/api/call_api_test.dart @@ -0,0 +1,66 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/call_api.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late CallApi callApi; + + setUp(() { + callApi = CallApi(client); + }); + + test('getCallToken should work', () async { + const callId = 'test-call-id'; + const path = '/calls/$callId'; + + when(() => client.post(path, data: {})).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await callApi.getCallToken(callId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('createCall should work', () async { + const callId = 'test-call-id'; + const callType = 'test-call-type'; + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const path = '/channels/$channelType/$channelId/call'; + + when(() => client.post( + path, + data: { + 'id': callId, + 'type': callType, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await callApi.createCall( + callId: callId, + callType: callType, + channelType: channelType, + channelId: channelId, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/requests_test.dart b/packages/stream_chat/test/src/core/api/requests_test.dart index 876a3f61..0bfdca7e 100644 --- a/packages/stream_chat/test/src/core/api/requests_test.dart +++ b/packages/stream_chat/test/src/core/api/requests_test.dart @@ -26,6 +26,56 @@ void main() { } }, ); + + test('copyWith', () { + final params = PaginationParams( + offset: 10, + limit: 20, + createdAtAfter: DateTime.now(), + createdAtAfterOrEqual: DateTime.now(), + createdAtAround: DateTime.now(), + createdAtBefore: DateTime.now(), + createdAtBeforeOrEqual: DateTime.now(), + greaterThan: 'greater-than', + greaterThanOrEqual: 'greater-than-or-equal', + lessThan: 'less-than', + lessThanOrEqual: 'less-than-or-equal', + idAround: 'id-around', + ); + + final sameOld = params.copyWith(); + expect(sameOld, equals(params)); + + final newDateTime = DateTime.now().add(const Duration(days: 2)); + const newTestString = 'test'; + final newParams = params.copyWith( + limit: 2, + offset: 2, + createdAtAfter: newDateTime, + createdAtAfterOrEqual: newDateTime, + createdAtAround: newDateTime, + createdAtBefore: newDateTime, + createdAtBeforeOrEqual: newDateTime, + greaterThan: newTestString, + greaterThanOrEqual: newTestString, + lessThan: newTestString, + lessThanOrEqual: newTestString, + idAround: newTestString, + ); + + expect(newParams.limit, 2); + expect(newParams.offset, 2); + expect(newParams.createdAtAfter, newDateTime); + expect(newParams.createdAtAfterOrEqual, newDateTime); + expect(newParams.createdAtAround, newDateTime); + expect(newParams.createdAtBefore, newDateTime); + expect(newParams.createdAtBeforeOrEqual, newDateTime); + expect(newParams.greaterThan, newTestString); + expect(newParams.greaterThanOrEqual, newTestString); + expect(newParams.lessThan, newTestString); + expect(newParams.lessThanOrEqual, newTestString); + expect(newParams.idAround, newTestString); + }); }); }); } diff --git a/packages/stream_chat/test/src/core/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart index 8a73fccd..073d2b40 100644 --- a/packages/stream_chat/test/src/core/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:stream_chat/src/core/models/call_payload.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; @@ -4340,5 +4341,31 @@ void main() { expect(response.members, isA>()); expect(response.message, isA()); }); + + test('CallTokenPayload', () { + const jsonExample = ''' + {"duration": "3ms", + "agora_app_id":"test", + "agora_uid": 12, + "token": "token"} + '''; + final response = CallTokenPayload.fromJson(json.decode(jsonExample)); + expect(response.agoraAppId, isA()); + expect(response.agoraUid, isA()); + expect(response.token, isA()); + }); + + test('CreateCallPayload', () { + const jsonExample = ''' + {"call": + {"id":"test", + "provider": "test", + "agora": {"channel":"test"}, + "hms":{"room_id":"test", "room_name":"test"} + }} + '''; + final response = CreateCallPayload.fromJson(json.decode(jsonExample)); + expect(response.call, isA()); + }); }); } diff --git a/packages/stream_chat/test/src/core/models/call_payload_test.dart b/packages/stream_chat/test/src/core/models/call_payload_test.dart new file mode 100644 index 00000000..a67e3bee --- /dev/null +++ b/packages/stream_chat/test/src/core/models/call_payload_test.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/models/call_payload.dart'; +import 'package:test/test.dart'; + +void main() { + test('CallPayload', () { + const jsonExample = ''' + {"id":"test", + "provider": "test", + "agora": {"channel":"test"}, + "hms":{"room_id":"test", "room_name":"test"} + } + '''; + final response = CallPayload.fromJson(json.decode(jsonExample)); + expect(response.agora, isA()); + expect(response.hms, isA()); + expect(response.id, isA()); + expect(response.provider, isA()); + }); + + test('AgoraPayload', () { + const jsonExample = ''' + {"channel":"test"} + '''; + final response = AgoraPayload.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + }); + + test('HMSPayload', () { + const jsonExample = ''' + {"room_id":"test", "room_name":"test"} + '''; + final response = HMSPayload.fromJson(json.decode(jsonExample)); + expect(response.roomId, isA()); + expect(response.roomName, isA()); + }); +}