From cd7ce964ff8972fcddf06070c26c7785f9bb7ae8 Mon Sep 17 00:00:00 2001 From: Alexander Emelin Date: Fri, 19 Nov 2021 16:36:07 +0300 Subject: [PATCH] Add futures, error stream, proper timeout passing, fire disconnect on initial connect error, and more (#57) --- CHANGELOG.md | 20 ++ README.md | 68 ++++--- .../res/drawable-v21/launch_background.xml | 12 ++ .../app/src/main/res/values-night/styles.xml | 18 ++ example/chat_app/ios/.gitignore | 2 + .../ios/Flutter/AppFrameworkInfo.plist | 4 +- example/chat_app/ios/Podfile | 41 +++++ example/chat_app/ios/Podfile.lock | 29 +++ .../ios/Runner.xcodeproj/project.pbxproj | 108 +++++++---- .../contents.xcworkspacedata | 2 +- .../contents.xcworkspacedata | 3 + example/chat_app/lib/chat.dart | 7 +- example/chat_app/lib/client/client.dart | 23 ++- example/chat_app/lib/conf.dart | 2 +- example/chat_app/lib/main.dart | 5 +- example/chat_app/pubspec.yaml | 8 +- example/chat_app/test/widget_test.dart | 29 +++ .../chat_app/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes .../chat_app/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes example/console/{simple.dart => example.dart} | 49 +++-- example/console_server_subs/example.dart | 9 +- example/console_server_subs/readme.md | 2 + .../ios/Flutter/AppFrameworkInfo.plist | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 4 +- example/flutter_app/lib/main.dart | 8 +- lib/src/client.dart | 171 ++++++++++++------ lib/src/client_config.dart | 2 +- lib/src/events.dart | 105 +++++++++-- lib/src/subscription.dart | 71 +++++--- lib/src/transport.dart | 63 +++++-- test/client_test.dart | 29 +-- test/src/utils.dart | 24 ++- test/subscription_test.dart | 87 ++++----- test/transport_test.dart | 6 +- 34 files changed, 727 insertions(+), 286 deletions(-) create mode 100644 example/chat_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 example/chat_app/android/app/src/main/res/values-night/styles.xml create mode 100644 example/chat_app/ios/Podfile create mode 100644 example/chat_app/ios/Podfile.lock create mode 100644 example/chat_app/test/widget_test.dart create mode 100644 example/chat_app/web/icons/Icon-maskable-192.png create mode 100644 example/chat_app/web/icons/Icon-maskable-512.png rename example/console/{simple.dart => example.dart} (61%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9df0cb..12c0f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [0.8.0] + +Version 0.8.0 is the next iteration of `centrifuge-dart` development. It pushes client closer to other clients in the ecosystem. It also **contains several backwards incompatible changes**. + +* Return Futures from `Client.connect`, `Client.disconnect`, `Subscription.subscribe`, `Subscription.unsubscribe` methods - addresses [#31](https://github.com/centrifugal/centrifuge-dart/issues/31). +* On initial connect fire `DisconnectEvent` on connection error - this makes behavior of `centrifuge-dart` similar to all other our clients - addresses [#56](https://github.com/centrifugal/centrifuge-dart/issues/56). +* Add client error stream to consume `ErrorEvent` - each transport failure will emit error to this stream - addresses [#56](https://github.com/centrifugal/centrifuge-dart/issues/56). +* Refactor subscription statuses - add `subscribing` and `error` statuses. This change is mostly internal should not affect working with Subscriptions. +* Do not call `UnsubscribeEvent` if subscription is not successfully subscribed (i.e. in `subscribed` state). This makes behavior of `centrifuge-dart` similar to all other our clients. +* Update disconnect reasons due to failed connection and calling `Client.Disconnect` method - make it more similar to all other connector libraries in ecosystem. +* Add default transport timeout (10 sec) – on connect and subscribe timeouts client will auto reconnect, calls like publish, history, rpc can now throw `TimeoutException`. Also - properly pass timeout to the transport (was not before!). Again – this makes client behave similarly to all other connectors. +* Add `presence` and `presenceStats` methods for Subscription and on client top level (for server-side subscriptions). +* Support `streamPosition` in `SubscribeSuccessEvent`. +* Support `streamPosition` in `ServerSubscribeEvent`. +* Support `data` in `ServerSubscribeEvent`. +* Implement `send` method to send async messages to a server. +* Fix deletion during iteration over map when working with server-side subscriptions. +* Better event String representations. +* Improvements and fixes in examples. + ## [0.7.1] * Add support for `data` in `SubscribeSuccessEvent`. This is a custom data which can be sent by a server towards client connection in subscribe result. Note that due to the bug in Centrifugo server this feature only works in Centrifugo >= v3.0.3. diff --git a/README.md b/README.md index ba4c710..cf35c54 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ [![Build Status](https://travis-ci.org/centrifugal/centrifuge-dart.svg?branch=master)](https://travis-ci.org/centrifugal/centrifuge-dart) [![Coverage Status](https://coveralls.io/repos/github/centrifugal/centrifuge-dart/badge.svg?branch=master)](https://coveralls.io/github/centrifugal/centrifuge-dart?branch=master) +This repo contains a Dart connector library to communicate with Centrifugo server or a server based on Centrifuge library for Go language. This client uses WebSocket transport with binary Protobuf protocol format for Centrifuge protocol message encoding. See feature matrix below to find out which protocol features are supported here at the moment. + ## Example -Examples: * `example\flutter_app` simple chat application -* `example\console` simple console application +* `example\chat_app` one more chat example +* `example\console` simple console application +* `example\console_server_subs` demonstrates working with server-side subscriptions ## Usage -Create client: +Create a client instance: ```dart import 'package:centrifuge/centrifuge.dart' as centrifuge; @@ -17,24 +20,27 @@ import 'package:centrifuge/centrifuge.dart' as centrifuge; final client = centrifuge.createClient("ws://localhost:8000/connection/websocket?format=protobuf"); ``` -**Note that** `?format=protobuf` **is required because this library only works with Protobuf protocol.** While this client uses binary Protobuf protocol internally nothing stops you from sending JSON-encoded data over it. +**Note that using** `?format=protobuf` **is required for Centrifugo < v3 and can be skipped for later versions**. + +Centrifuge-dart uses binary Protobuf protocol internally but nothing stops you from sending JSON-encoded data over it. Our examples demonstrate this. + +Connect to a server: -Connect to server: ```dart -client.connect(); +await client.connect(); ``` -Note that `.connect()` method is asynchronous. This means that client will be properly connected and authenticated on server at some point in future. To handle connect and disconnect events you can listen to `connectStream` and `disconnectStream`: +To handle connect and disconnect events you can listen to `connectStream` and `disconnectStream`: ```dart client.connectStream.listen(onEvent); client.disconnectStream.listen(onEvent); -client.connect(); +await client.connect(); ``` Connect and disconnect events can happen many times throughout client lifetime. -Subscribe to channel: +Subscribe to a channel: ```dart final subscription = client.getSubscription(channel); @@ -46,16 +52,30 @@ subscription.subscribeSuccessStream.listen(onEvent); subscription.subscribeErrorStream.listen(onEvent); subscription.unsubscribeStream.listen(onEvent); -subscription.subscribe(); +await subscription.subscribe(); ``` -Publish: +Publish to a channel: + ```dart -final output = jsonEncode({'input': message}); -final data = utf8.encode(output); +final data = utf8.encode(jsonEncode({'input': message})); await subscription.publish(data); ``` +When using server-side subscriptions you don't need to create Subscription instances, just set appropriate event handlers on `Client` instance: + +```dart +client.connectStream.listen(onEvent); +client.disconnectStream.listen(onEvent); +client.subscribeStream.listen(onEvent); +client.publishStream.listen(onEvent); +await client.connect(); +``` + +## Usage in background + +When mobile application goes to background there are many OS-specific limitations for established persistent connections. Thus in most cases you need to disconnect from a server when app moves to background and connect again when app goes to foreground. + ## Feature matrix - [ ] connect to server using JSON protocol format @@ -69,18 +89,17 @@ await subscription.publish(data); - [x] subscribe on channel and handle asynchronous Publications - [x] handle Join and Leave messages - [x] handle Unsubscribe notifications -- [ ] reconnect on subscribe timeout +- [x] reconnect on subscribe timeout - [x] publish method of Subscription - [x] unsubscribe method of Subscription -- [ ] presence method of Subscription -- [ ] presence stats method of Subscription +- [x] presence method of Subscription +- [x] presence stats method of Subscription - [x] history method of Subscription - [x] top-level publish method -- [ ] top-level presence method -- [ ] top-level presence stats method -- [ ] top-level history method -- [ ] top-level unsubscribe method -- [ ] send asynchronous messages to server +- [x] top-level presence method +- [x] top-level presence stats method +- [x] top-level history method +- [x] send asynchronous messages to server - [x] handle asynchronous messages from server - [x] send RPC commands - [x] subscribe to private channels with token (JWT) @@ -93,15 +112,18 @@ await subscription.publish(data); - [x] server-side subscriptions - [x] message recovery mechanism for server-side subscriptions - [x] history stream pagination +- [ ] subscribe from the known StreamPosition + +## Instructions for maintainers/contributors -## Instructions to update protobuf +### How to update protobuf definitions 1) Install `protoc` compiler 2) Install `protoc_plugin` https://pub.dev/packages/protoc_plugin (`dart pub global activate protoc_plugin`) 3) cd `lib/src/proto` and run `protoc --dart_out=. -I . client.proto` 4) cd to root and run `dartfmt -w lib/ test/` (install dartfmt with `dart pub global activate dart_style`) -## Instructions to release +### How to release 1) Update changelog 2) Bump version in `pubspec.yaml`, push, create new tag diff --git a/example/chat_app/android/app/src/main/res/drawable-v21/launch_background.xml b/example/chat_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/chat_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/chat_app/android/app/src/main/res/values-night/styles.xml b/example/chat_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..449a9f9 --- /dev/null +++ b/example/chat_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/chat_app/ios/.gitignore b/example/chat_app/ios/.gitignore index e96ef60..7a7f987 100644 --- a/example/chat_app/ios/.gitignore +++ b/example/chat_app/ios/.gitignore @@ -1,3 +1,4 @@ +**/dgph *.mode1v3 *.mode2v3 *.moved-aside @@ -18,6 +19,7 @@ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig +Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ diff --git a/example/chat_app/ios/Flutter/AppFrameworkInfo.plist b/example/chat_app/ios/Flutter/AppFrameworkInfo.plist index 6b4c0f7..8d4492f 100644 --- a/example/chat_app/ios/Flutter/AppFrameworkInfo.plist +++ b/example/chat_app/ios/Flutter/AppFrameworkInfo.plist @@ -3,7 +3,7 @@ CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + en CFBundleExecutable App CFBundleIdentifier @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 9.0 diff --git a/example/chat_app/ios/Podfile b/example/chat_app/ios/Podfile new file mode 100644 index 0000000..1e8c3c9 --- /dev/null +++ b/example/chat_app/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '9.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/chat_app/ios/Podfile.lock b/example/chat_app/ios/Podfile.lock new file mode 100644 index 0000000..736c829 --- /dev/null +++ b/example/chat_app/ios/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - Flutter (1.0.0) + - fluttertoast (0.0.2): + - Flutter + - Toast + - Toast (4.0.0) + +DEPENDENCIES: + - Flutter (from `Flutter`) + - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + +SPEC REPOS: + trunk: + - Toast + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + fluttertoast: + :path: ".symlinks/plugins/fluttertoast/ios" + +SPEC CHECKSUMS: + Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a + fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58 + Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + +PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c + +COCOAPODS: 1.10.1 diff --git a/example/chat_app/ios/Runner.xcodeproj/project.pbxproj b/example/chat_app/ios/Runner.xcodeproj/project.pbxproj index 460998b..b55d424 100644 --- a/example/chat_app/ios/Runner.xcodeproj/project.pbxproj +++ b/example/chat_app/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 51; objects = { /* Begin PBXBuildFile section */ @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + ABEA171B55FFB2A81ADA03F7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7307FD380A155E6D6606F523 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -31,7 +32,10 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 231AF589AAFB7677B49A1E6C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 71F06C503256F005E37B2C47 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 7307FD380A155E6D6606F523 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -42,6 +46,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CDBE7AC3FB3B19918C20AD3F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,6 +54,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + ABEA171B55FFB2A81ADA03F7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -72,6 +78,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + B491EE30079DB170743EA43D /* Pods */, + FBBE3D76270830AC332CF9B0 /* Frameworks */, ); sourceTree = ""; }; @@ -90,7 +98,6 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, - 97C146F11CF9000F007C117D /* Supporting Files */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -99,11 +106,22 @@ path = Runner; sourceTree = ""; }; - 97C146F11CF9000F007C117D /* Supporting Files */ = { + B491EE30079DB170743EA43D /* Pods */ = { isa = PBXGroup; children = ( + CDBE7AC3FB3B19918C20AD3F /* Pods-Runner.debug.xcconfig */, + 231AF589AAFB7677B49A1E6C /* Pods-Runner.release.xcconfig */, + 71F06C503256F005E37B2C47 /* Pods-Runner.profile.xcconfig */, ); - name = "Supporting Files"; + path = Pods; + sourceTree = ""; + }; + FBBE3D76270830AC332CF9B0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7307FD380A155E6D6606F523 /* Pods_Runner.framework */, + ); + name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ @@ -113,12 +131,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + EB1D4FB86397CA4F3722F9C2 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + AB6677CC13910C36E286B158 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -205,6 +225,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + AB6677CC13910C36E286B158 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + EB1D4FB86397CA4F3722F9C2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -241,7 +300,6 @@ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -281,7 +339,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -298,15 +356,10 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Flutter", + "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.chatApp; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -318,7 +371,6 @@ }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -364,7 +416,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -374,7 +426,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -414,11 +465,12 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; @@ -432,15 +484,12 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); + "EXCLUDED_ARCHS[sdk=*]" = "i386 arm64"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Flutter", + "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.chatApp; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -459,15 +508,10 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Flutter", + "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.chatApp; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/example/chat_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/chat_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a1..919434a 100644 --- a/example/chat_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/example/chat_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/example/chat_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/chat_app/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/example/chat_app/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/example/chat_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/example/chat_app/lib/chat.dart b/example/chat_app/lib/chat.dart index 5096ab8..ee0032c 100644 --- a/example/chat_app/lib/chat.dart +++ b/example/chat_app/lib/chat.dart @@ -26,13 +26,16 @@ class _ChatPageState extends State { onSend: (msg) async { await conf.cli.sendMsg(msg); }, + onLoadEarlier: () { + print("loading..."); + }, ), ); } @override - void dispose() { - _sub.cancel(); + Future dispose() async { + await _sub.cancel(); super.dispose(); } } diff --git a/example/chat_app/lib/client/client.dart b/example/chat_app/lib/client/client.dart index 9fc678d..124a09f 100644 --- a/example/chat_app/lib/client/client.dart +++ b/example/chat_app/lib/client/client.dart @@ -14,6 +14,7 @@ class ChatClient { StreamSubscription? _connSub; StreamSubscription? _disconnSub; + StreamSubscription? _errorSub; late StreamSubscription _msgSub; @@ -35,7 +36,7 @@ class ChatClient { }); } - void connect(VoidCallback onConnect) { + Future connect(VoidCallback onConnect) async { print("Connecting to Centrifugo server at ${conf.serverAddr}"); _connSub = _client.connectStream.listen((event) { print("Connected to server"); @@ -52,10 +53,13 @@ class ChatClient { backgroundColor: Colors.red, textColor: Colors.white); }); - _client.connect(); + _errorSub = _client.errorStream.listen((event) { + print(event.error); + }); + await _client.connect(); } - void subscribe(String channel) { + Future subscribe(String channel) async { print("Subscribing to channel $channel"); final subscription = _client.getSubscription(channel); subscription.publishStream @@ -78,16 +82,15 @@ class ChatClient { subscription.subscribeSuccessStream.listen(print); subscription.subscribeErrorStream.listen(print); subscription.unsubscribeStream.listen(print); - subscription.subscribe(); - this.subscription = subscription; + await subscription.subscribe(); } - void dispose() { - _connSub?.cancel(); - _disconnSub?.cancel(); - _msgSub.cancel(); - _chatMsgController.close(); + Future dispose() async { + await _connSub?.cancel(); + await _disconnSub?.cancel(); + await _msgSub.cancel(); + await _chatMsgController.close(); } Future sendMsg(ChatMessage msg) async { diff --git a/example/chat_app/lib/conf.dart b/example/chat_app/lib/conf.dart index dc1c366..3a8d360 100644 --- a/example/chat_app/lib/conf.dart +++ b/example/chat_app/lib/conf.dart @@ -1,6 +1,6 @@ import 'client/client.dart'; -const String serverAddr = "centrifugo2.herokuapp.com"; +const String serverAddr = "localhost:8000"; // a demo token const String token = diff --git a/example/chat_app/lib/main.dart b/example/chat_app/lib/main.dart index 466746b..e63fa3b 100644 --- a/example/chat_app/lib/main.dart +++ b/example/chat_app/lib/main.dart @@ -7,7 +7,10 @@ final Map routes = { '/chat': (BuildContext context) => const ChatPage(), }; -void main() => runApp(MyApp()); +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + runApp(MyApp()); +} class MyApp extends StatelessWidget { @override diff --git a/example/chat_app/pubspec.yaml b/example/chat_app/pubspec.yaml index 2e0d402..0e996c6 100644 --- a/example/chat_app/pubspec.yaml +++ b/example/chat_app/pubspec.yaml @@ -17,13 +17,13 @@ dependencies: extra_pedantic: ^1.1.1+3 flutter: sdk: flutter - fluttertoast: ^8.0.6 + fluttertoast: ^8.0.8 pedantic: ^1.8.0 responsive_builder: ^0.1.5 -dependency_overrides: - dash_chat: - git: git@github.com:fayeed/dash_chat.git +# dependency_overrides: +# dash_chat: +# git: git@github.com:fayeed/dash_chat.git dev_dependencies: flutter_test: diff --git a/example/chat_app/test/widget_test.dart b/example/chat_app/test/widget_test.dart new file mode 100644 index 0000000..805641d --- /dev/null +++ b/example/chat_app/test/widget_test.dart @@ -0,0 +1,29 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:chat_app/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/example/chat_app/web/icons/Icon-maskable-192.png b/example/chat_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/example/chat_app/web/icons/Icon-maskable-512.png b/example/chat_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/example/console/simple.dart b/example/console/example.dart similarity index 61% rename from example/console/simple.dart rename to example/console/example.dart index 5b87b01..2b704e0 100644 --- a/example/console/simple.dart +++ b/example/console/example.dart @@ -8,15 +8,19 @@ void main() async { final url = 'ws://localhost:8000/connection/websocket?format=protobuf'; final channel = 'chat:index'; + final onSubscriptionEvent = (dynamic event) { + print('subscription $channel> $event'); + }; + final onEvent = (dynamic event) { - print('$channel> $event'); + print('client> $event'); }; try { final client = centrifuge.createClient( url, config: centrifuge.ClientConfig( - headers: {'user-id': 42, 'user-name': 'The Answer'}, + headers: {'X-Example-Header': 'example'}, onPrivateSub: (centrifuge.PrivateSubEvent event) { return Future.value(''); }), @@ -24,22 +28,23 @@ void main() async { client.connectStream.listen(onEvent); client.disconnectStream.listen(onEvent); + client.errorStream.listen(onEvent); // Uncomment to use example token based on secret key `secret`. // client.setToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0c3VpdGVfand0In0.hPmHsVqvtY88PvK4EmJlcdwNuKFuy3BGaF7dMaKdPlw'); - client.connect(); + await client.connect(); final subscription = client.getSubscription(channel); - subscription.publishStream.map((e) => utf8.decode(e.data)).listen(onEvent); - subscription.joinStream.listen(onEvent); - subscription.leaveStream.listen(onEvent); + subscription.publishStream.listen(onSubscriptionEvent); + subscription.joinStream.listen(onSubscriptionEvent); + subscription.leaveStream.listen(onSubscriptionEvent); - subscription.subscribeSuccessStream.listen(onEvent); - subscription.subscribeErrorStream.listen(onEvent); - subscription.unsubscribeStream.listen(onEvent); + subscription.subscribeSuccessStream.listen(onSubscriptionEvent); + subscription.subscribeErrorStream.listen(onSubscriptionEvent); + subscription.unsubscribeStream.listen(onSubscriptionEvent); - subscription.subscribe(); + await subscription.subscribe(); final handler = _handleUserInput(client, subscription); @@ -57,13 +62,13 @@ Function(String) _handleUserInput( return (String message) async { switch (message) { case '#subscribe': - subscription.subscribe(); + await subscription.subscribe(); break; case '#unsubscribe': - subscription.unsubscribe(); + await subscription.unsubscribe(); break; case '#connect': - client.connect(); + await client.connect(); break; case '#rpc': final request = jsonEncode({'param': 'test'}); @@ -71,13 +76,25 @@ Function(String) _handleUserInput( final result = await client.rpc('test', data); print('RPC result: ' + utf8.decode(result.data)); break; + case '#presence': + final result = await subscription.presence(); + print(result); + break; + case '#presenceStats': + final result = await subscription.presenceStats(); + print(result); + break; case '#history': final result = await subscription.history(limit: 10); - print('History num publications: ' + result.publications.length.toString()); - print('Stream top position: ' + result.offset.toString() + ', epoch: ' + result.epoch); + print('History num publications: ' + + result.publications.length.toString()); + print('Stream top position: ' + + result.offset.toString() + + ', epoch: ' + + result.epoch); break; case '#disconnect': - client.disconnect(); + await client.disconnect(); break; default: final output = jsonEncode({'input': message}); diff --git a/example/console_server_subs/example.dart b/example/console_server_subs/example.dart index d1cbf06..3af866f 100644 --- a/example/console_server_subs/example.dart +++ b/example/console_server_subs/example.dart @@ -26,7 +26,7 @@ void main() async { // Uncomment to use example token based on secret key `secret`. // client.setToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0c3VpdGVfand0In0.hPmHsVqvtY88PvK4EmJlcdwNuKFuy3BGaF7dMaKdPlw'); - client.connect(); + await client.connect(); final handler = _handleUserInput(client); @@ -39,15 +39,14 @@ void main() async { } } -Function(String) _handleUserInput( - centrifuge.Client client) { +Function(String) _handleUserInput(centrifuge.Client client) { return (String message) async { switch (message) { case '#connect': - client.connect(); + await client.connect(); break; case '#disconnect': - client.disconnect(); + await client.disconnect(); break; default: break; diff --git a/example/console_server_subs/readme.md b/example/console_server_subs/readme.md index f791ebb..26e892b 100644 --- a/example/console_server_subs/readme.md +++ b/example/console_server_subs/readme.md @@ -1 +1,3 @@ Example that uses server-side subscriptions. + +You may enable `user_subscribe_to_personal` option in Centrifugo so that server will subscribe connection to a channel. diff --git a/example/flutter_app/ios/Flutter/AppFrameworkInfo.plist b/example/flutter_app/ios/Flutter/AppFrameworkInfo.plist index 9367d48..8d4492f 100644 --- a/example/flutter_app/ios/Flutter/AppFrameworkInfo.plist +++ b/example/flutter_app/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 9.0 diff --git a/example/flutter_app/ios/Runner.xcodeproj/project.pbxproj b/example/flutter_app/ios/Runner.xcodeproj/project.pbxproj index 991bd8a..801608b 100644 --- a/example/flutter_app/ios/Runner.xcodeproj/project.pbxproj +++ b/example/flutter_app/ios/Runner.xcodeproj/project.pbxproj @@ -309,7 +309,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -357,7 +357,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/example/flutter_app/lib/main.dart b/example/flutter_app/lib/main.dart index 4d822d5..1aaba38 100644 --- a/example/flutter_app/lib/main.dart +++ b/example/flutter_app/lib/main.dart @@ -46,8 +46,8 @@ class _MyHomePageState extends State { } @override - void dispose() { - _centrifuge.disconnect(); + void dispose() async { + await _centrifuge.disconnect(); super.dispose(); } @@ -87,7 +87,7 @@ class _MyHomePageState extends State { void _connect() async { try { - _centrifuge.connect(); + await _centrifuge.connect(); } catch (exception) { _show(exception); } @@ -135,7 +135,7 @@ class _MyHomePageState extends State { onNewItem(item); }); - _subscription.subscribe(); + await _subscription.subscribe(); } void _show(dynamic error) { diff --git a/lib/src/client.dart b/lib/src/client.dart index a9c5126..73f23c3 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1,9 +1,8 @@ import 'dart:async'; -import 'package:centrifuge/src/transport.dart'; import 'package:centrifuge/src/server_subscription.dart'; +import 'package:centrifuge/src/transport.dart'; import 'package:meta/meta.dart'; -import 'package:protobuf/protobuf.dart'; import 'client_config.dart'; import 'events.dart'; @@ -20,6 +19,8 @@ Client createClient(String url, {ClientConfig? config}) => ClientImpl( abstract class Client { Stream get connectStream; + Stream get errorStream; + Stream get disconnectStream; Stream get messageStream; @@ -36,7 +37,7 @@ abstract class Client { /// Connect to the server. /// - void connect(); + Future connect(); /// Set token for connection request. /// @@ -44,6 +45,7 @@ abstract class Client { /// connection request. /// /// To remove previous token, call with null. + /// void setToken(String token); /// Set data for connection request. @@ -52,27 +54,39 @@ abstract class Client { /// connection request. /// /// To remove previous connectData, call with null. + /// void setConnectData(List connectData); - /// Publish data to the channel + // Send asynchronous message to a server. This method makes sense + // only when using Centrifuge library for Go on a server side. In Centrifugo + // asynchronous message handler does not exist. + /// + Future send(List data); + + /// Publish data to the channel. /// Future publish(String channel, List data); - /// Send RPC command + /// Send RPC command. /// Future rpc(String method, List data); - /// Send History command + /// Send History command. /// Future history(String channel, - {int limit = 0, StreamPosition? since}); + {int limit = 0, StreamPosition? since, bool reverse = false}); - @alwaysThrows - Future send(List data); + /// Send Presence command. + /// + Future presence(String channel); + + /// Send PresenceStats command. + /// + Future presenceStats(String channel); /// Disconnect from the server. /// - void disconnect(); + Future disconnect(); /// Detect that the subscription already exists. /// @@ -89,7 +103,7 @@ abstract class Client { void removeSubscription(Subscription subscription); } -class ClientImpl implements Client, GeneratedMessageSender { +class ClientImpl implements Client { ClientImpl(this._url, this._config, this._transportBuilder); final TransportBuilder _transportBuilder; @@ -105,8 +119,10 @@ class ClientImpl implements Client, GeneratedMessageSender { ClientConfig? get config => _config; List? _connectData; String? _clientID; + bool _new = true; final _connectController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); final _disconnectController = StreamController.broadcast(); final _messageController = StreamController.broadcast(); final _subscribeController = @@ -122,6 +138,9 @@ class ClientImpl implements Client, GeneratedMessageSender { @override Stream get connectStream => _connectController.stream; + @override + Stream get errorStream => _errorController.stream; + @override Stream get disconnectStream => _disconnectController.stream; @@ -146,11 +165,7 @@ class ClientImpl implements Client, GeneratedMessageSender { Stream get leaveStream => _leaveController.stream; @override - void connect() async { - return _connect(); - } - - bool get connected => _state == _ClientState.connected; + Future connect() async => await _connect(); @override void setToken(String token) => _token = token; @@ -196,14 +211,31 @@ class ClientImpl implements Client, GeneratedMessageSender { } @override - @alwaysThrows + Future presence(String channel) async { + final request = protocol.PresenceRequest()..channel = channel; + final result = + await _transport.sendMessage(request, protocol.PresenceResult()); + return PresenceResult.from(result); + } + + @override + Future presenceStats(String channel) async { + final request = protocol.PresenceStatsRequest()..channel = channel; + final result = + await _transport.sendMessage(request, protocol.PresenceStatsResult()); + return PresenceStatsResult.from(result); + } + + @override Future send(List data) async { - throw UnimplementedError; + final request = protocol.Message()..data = data; + await _transport.sendAsyncMessage(request); } @override - void disconnect() async { - _processDisconnect(reason: 'manual disconnect', reconnect: false); + Future disconnect() async { + _processDisconnect(reason: 'client disconnect', reconnect: false); + _new = true; await _transport.close(); } @@ -232,18 +264,6 @@ class ClientImpl implements Client, GeneratedMessageSender { _subscriptions.remove(channel); } - Future unsubscribe(String channel) async { - final request = protocol.UnsubscribeRequest()..channel = channel; - await _transport.sendMessage(request, protocol.UnsubscribeResult()); - return UnsubscribeEvent(); - } - - @override - Future - sendMessage( - Req request, Rep result) => - _transport.sendMessage(request, result); - int _retryCount = 0; void _processDisconnect( @@ -253,26 +273,35 @@ class ClientImpl implements Client, GeneratedMessageSender { } _clientID = ''; - if (_state == _ClientState.connected) { - _subscriptions.values.forEach((s) => s.sendUnsubscribeEventIfNeeded()); + if (_state == _ClientState.connected || + (_state == _ClientState.connecting && _new)) { + _subscriptions.values.forEach((s) => s.unsubscribeOnDisconnect()); _serverSubs.forEach((key, value) { final event = ServerUnsubscribeEvent.from(key); _unsubscribeController.add(event); }); final disconnect = DisconnectEvent(reason, reconnect); _disconnectController.add(disconnect); + _new = false; } if (reconnect) { _state = _ClientState.connecting; - _retryCount += 1; - await _config.retry(_retryCount); - _connect(); + scheduleReconnect(); } else { _state = _ClientState.disconnected; } } + void scheduleReconnect() async { + _retryCount += 1; + await _config.retry(_retryCount); + if (_state == _ClientState.disconnected) { + return; + } + _connect(); + } + Future _connect() async { try { _state = _ClientState.connecting; @@ -280,15 +309,24 @@ class ClientImpl implements Client, GeneratedMessageSender { _transport = _transportBuilder( url: _url, config: TransportConfig( - headers: _config.headers, pingInterval: _config.pingInterval)); - - await _transport.open( - _onPush, - onError: (dynamic error) => - _processDisconnect(reason: error.toString(), reconnect: true), - onDone: (reason, reconnect) => - _processDisconnect(reason: reason, reconnect: reconnect), - ); + headers: _config.headers, + pingInterval: _config.pingInterval, + timeout: _config.timeout)); + + await _transport.open(_onPush, onError: (dynamic error) { + final event = ErrorEvent(error); + _errorController.add(event); + if (_state != _ClientState.connected) { + return; + } + _processDisconnect(reason: "connection closed", reconnect: true); + }, onDone: (reason, reconnect) { + if (_state != _ClientState.connected && + !(_state == _ClientState.connecting && _new)) { + return; + } + _processDisconnect(reason: reason, reconnect: reconnect); + }); final request = protocol.ConnectRequest(); if (_token != null) { @@ -320,6 +358,7 @@ class ClientImpl implements Client, GeneratedMessageSender { _clientID = result.client; _retryCount = 0; _state = _ClientState.connected; + _new = false; _connectController.add(ConnectEvent.from(result)); result.subs.forEach((key, value) { @@ -337,17 +376,16 @@ class ClientImpl implements Client, GeneratedMessageSender { } }); }); - _serverSubs.forEach((key, value) { - if (!result.subs.containsKey(key)) { - _serverSubs.remove(key); - } - }); + _serverSubs.removeWhere((key, value) => !result.subs.containsKey(key)); for (SubscriptionImpl subscription in _subscriptions.values) { - subscription.resubscribeIfNeeded(); + subscription.resubscribeOnConnect(); } } catch (ex) { - _processDisconnect(reason: ex.toString(), reconnect: true); + final event = ErrorEvent(ex); + _errorController.add(event); + _processDisconnect(reason: "connect error", reconnect: true); + await _transport.close(); } } @@ -441,6 +479,33 @@ class ClientImpl implements Client, GeneratedMessageSender { bool _isPrivateChannel(String channel) => channel.startsWith(_config.privateChannelPrefix); + + @internal + Future sendUnsubscribe(String channel) async { + final request = protocol.UnsubscribeRequest()..channel = channel; + return await _transport.sendMessage(request, protocol.UnsubscribeResult()); + } + + @internal + Future sendSubscribe( + String channel, String? token) async { + final request = protocol.SubscribeRequest() + ..channel = channel + ..token = token ?? ''; + return await _transport.sendMessage(request, protocol.SubscribeResult()); + } + + @internal + void processDisconnect( + {required String reason, required bool reconnect}) async { + return _processDisconnect(reason: reason, reconnect: reconnect); + } + + @internal + void closeTransport() async => await _transport.close(); + + @internal + bool get connected => _state == _ClientState.connected; } enum _ClientState { connected, disconnected, connecting } diff --git a/lib/src/client_config.dart b/lib/src/client_config.dart index 2ea440d..202a434 100644 --- a/lib/src/client_config.dart +++ b/lib/src/client_config.dart @@ -6,7 +6,7 @@ import 'package:centrifuge/src/events.dart'; class ClientConfig { ClientConfig( - {this.timeout = const Duration(seconds: 5), + {this.timeout = const Duration(seconds: 10), this.debug = false, this.headers = const {}, this.tlsSkipVerify = false, diff --git a/lib/src/events.dart b/lib/src/events.dart index 38ba9d7..534046b 100644 --- a/lib/src/events.dart +++ b/lib/src/events.dart @@ -31,6 +31,17 @@ class ConnectEvent { } } +class ErrorEvent { + ErrorEvent(this.error); + + final dynamic error; + + @override + String toString() { + return 'ErrorEvent{error: $error}'; + } +} + class DisconnectEvent { DisconnectEvent(this.reason, this.shouldReconnect); @@ -66,7 +77,7 @@ class PublishEvent { @override String toString() { - return 'PublishEvent{data: $data}'; + return 'PublishEvent{data: ${utf8.decode(data, allowMalformed: true)}, offset: $offset, info: $info}'; } } @@ -84,7 +95,7 @@ class ServerPublishEvent { @override String toString() { - return 'ServerPublishEvent{channel: $channel, data: $data}'; + return 'ServerPublishEvent{channel: $channel, data: ${utf8.decode(data, allowMalformed: true)}, offset: $offset, info: $info}'; } } @@ -98,6 +109,11 @@ class ClientInfo { final String user; final List? connInfo; final List? chanInfo; + + @override + String toString() { + return 'ClientInfo{client: $client, user: $user}'; + } } class Publication { @@ -132,6 +148,43 @@ class HistoryResult { } } +class PresenceResult { + PresenceResult(this.presence); + + final Map presence; + + static PresenceResult from(proto.PresenceResult res) { + final presence = {}; + res.presence.forEach((clientId, ci) { + presence[clientId] = ClientInfo.from(ci); + }); + return PresenceResult( + presence, + ); + } + + @override + String toString() { + return 'PresenceResult{num clients: ${presence.length}}'; + } +} + +class PresenceStatsResult { + PresenceStatsResult(this.numClients, this.numUsers); + + final int numClients; + final int numUsers; + + static PresenceStatsResult from(proto.PresenceStatsResult res) { + return PresenceStatsResult(res.numClients, res.numUsers); + } + + @override + String toString() { + return 'PresenceStatsResult{num clients: $numClients, num users: $numUsers}'; + } +} + class JoinEvent { JoinEvent(this.user, this.client); @@ -195,40 +248,63 @@ class ServerLeaveEvent { } class SubscribeSuccessEvent { - SubscribeSuccessEvent(this.isResubscribed, this.isRecovered, this.data); + SubscribeSuccessEvent( + this.isResubscribed, this.isRecovered, this.data, this.streamPosition); final bool isResubscribed; final bool isRecovered; final List data; + final StreamPosition? streamPosition; static SubscribeSuccessEvent from( - proto.SubscribeResult result, bool resubscribed) => - SubscribeSuccessEvent(resubscribed, result.recovered, result.data); + proto.SubscribeResult result, bool resubscribed) { + StreamPosition? streamPosition; + if (result.positioned) { + streamPosition = StreamPosition(result.offset, result.epoch); + } + return SubscribeSuccessEvent( + resubscribed, result.recovered, result.data, streamPosition); + } @override String toString() { - return 'SubscribeSuccessEvent{isResubscribed: $isResubscribed, isRecovered: $isRecovered, data: ${utf8.decode(data, allowMalformed: true)}}'; + return 'SubscribeSuccessEvent{isResubscribed: $isResubscribed, isRecovered: $isRecovered, data: ${utf8.decode(data, allowMalformed: true)}, streamPosition: $streamPosition}'; } } class ServerSubscribeEvent { - ServerSubscribeEvent(this.channel, this.isResubscribed, this.isRecovered); + ServerSubscribeEvent(this.channel, this.isResubscribed, this.isRecovered, + this.data, this.streamPosition); final String channel; final bool isResubscribed; final bool isRecovered; + final List data; + final StreamPosition? streamPosition; static ServerSubscribeEvent fromSubscribeResult( - String channel, proto.SubscribeResult result, bool resubscribed) => - ServerSubscribeEvent(channel, resubscribed, result.recovered); + String channel, proto.SubscribeResult result, bool resubscribed) { + StreamPosition? streamPosition; + if (result.positioned) { + streamPosition = StreamPosition(result.offset, result.epoch); + } + return ServerSubscribeEvent( + channel, resubscribed, result.recovered, result.data, streamPosition); + } static ServerSubscribeEvent fromSubscribePush( - String channel, proto.Subscribe result, bool resubscribed) => - ServerSubscribeEvent(channel, resubscribed, false); + String channel, proto.Subscribe result, bool resubscribed) { + StreamPosition? streamPosition; + if (result.positioned) { + streamPosition = StreamPosition(result.offset, result.epoch); + } + return ServerSubscribeEvent( + channel, resubscribed, false, result.data, streamPosition); + } @override String toString() { - return 'ServerSubscribeEvent{channel: $channel, isResubscribed: $isResubscribed, isRecovered: $isRecovered}'; + return 'ServerSubscribeEvent{channel: $channel, isResubscribed: $isResubscribed, isRecovered: $isRecovered, data: ${utf8.decode(data, allowMalformed: true)}, streamPosition: $streamPosition}'; } } @@ -240,6 +316,11 @@ class StreamPosition { final $fixnum.Int64 offset; final String epoch; + + @override + String toString() { + return 'StreamPosition{offset: $offset, epoch: $epoch}'; + } } class SubscribeErrorEvent { diff --git a/lib/src/subscription.dart b/lib/src/subscription.dart index fd3c391..ad4dd75 100644 --- a/lib/src/subscription.dart +++ b/lib/src/subscription.dart @@ -20,12 +20,16 @@ abstract class Subscription { Stream get unsubscribeStream; - void subscribe(); + Future subscribe(); - void unsubscribe(); + Future unsubscribe(); Future publish(List data); + Future presence(); + + Future presenceStats(); + Future history({int limit = 0, StreamPosition? since}); } @@ -73,42 +77,44 @@ class SubscriptionImpl implements Subscription { _client.publish(channel, data); @override - void subscribe() { - _state = _SubscriptionState.subscribed; + Future subscribe() async { + if (_state != _SubscriptionState.unsubscribed) { + return; + } + _state = _SubscriptionState.subscribing; if (!_client.connected) { return; } - _resubscribe(isResubscribed: false); + await _resubscribe(isResubscribed: false); } - void resubscribeIfNeeded() { - if (_state != _SubscriptionState.subscribed) { + void resubscribeOnConnect() { + if (_state != _SubscriptionState.subscribing) { return null; } _resubscribe(isResubscribed: true); } @override - void unsubscribe() async { - if (_state != _SubscriptionState.subscribed) { + Future unsubscribe() async { + final prevState = _state; + _state = _SubscriptionState.unsubscribed; + if (prevState != _SubscriptionState.subscribed) { return; } - _state = _SubscriptionState.unsubscribed; - if (!_client.connected) { + addUnsubscribe(UnsubscribeEvent()); return; } - - final request = protocol.UnsubscribeRequest()..channel = channel; - await _client.sendMessage(request, protocol.UnsubscribeResult()); - final event = UnsubscribeEvent(); - addUnsubscribe(event); + await _client.sendUnsubscribe(channel); + addUnsubscribe(UnsubscribeEvent()); } - void sendUnsubscribeEventIfNeeded() { + void unsubscribeOnDisconnect() { if (_state != _SubscriptionState.subscribed) { return; } + _state = _SubscriptionState.subscribing; final event = UnsubscribeEvent(); addUnsubscribe(event); } @@ -118,14 +124,21 @@ class SubscriptionImpl implements Subscription { {int limit = 0, StreamPosition? since, bool reverse = false}) => _client.history(channel, limit: limit, since: since, reverse: reverse); + @override + Future presence() => _client.presence(channel); + + @override + Future presenceStats() => _client.presenceStats(channel); + void addPublish(PublishEvent event) => _publishController.add(event); void addJoin(JoinEvent event) => _joinController.add(event); void addLeave(LeaveEvent event) => _leaveController.add(event); - void _onSubscribeSuccess(SubscribeSuccessEvent event) => - _subscribeSuccessController.add(event); + void _onSubscribeSuccess(SubscribeSuccessEvent event) { + _subscribeSuccessController.add(event); + } void _onSubscribeError(SubscribeErrorEvent event) => _subscribeErrorController.add(event); @@ -136,17 +149,23 @@ class SubscriptionImpl implements Subscription { Future _resubscribe({required bool isResubscribed}) async { try { final token = await _client.getToken(channel); - final request = protocol.SubscribeRequest() - ..channel = channel - ..token = token ?? ''; - - final result = - await _client.sendMessage(request, protocol.SubscribeResult()); + final result = await _client.sendSubscribe(channel, token); final event = SubscribeSuccessEvent.from(result, isResubscribed); + _state = _SubscriptionState.subscribed; _onSubscribeSuccess(event); _recover(result); + } on TimeoutException { + _client.processDisconnect(reason: 'subscribe timeout', reconnect: true); + _client.closeTransport(); + return; } catch (exception) { + _state = _SubscriptionState.error; if (exception is errors.Error) { + if (exception.code == 100) { + _client.processDisconnect(reason: 'subscribe error', reconnect: true); + _client.closeTransport(); + return; + } _onSubscribeError( SubscribeErrorEvent(exception.message, exception.code)); } else { @@ -163,4 +182,4 @@ class SubscriptionImpl implements Subscription { } } -enum _SubscriptionState { subscribed, unsubscribed } +enum _SubscriptionState { unsubscribed, subscribing, subscribed, error } diff --git a/lib/src/transport.dart b/lib/src/transport.dart index b028782..62f437a 100644 --- a/lib/src/transport.dart +++ b/lib/src/transport.dart @@ -18,10 +18,12 @@ typedef WebSocketBuilder = Future Function(); class TransportConfig { TransportConfig( {this.pingInterval = const Duration(seconds: 25), - this.headers = const {}}); + this.headers = const {}, + this.timeout = const Duration(seconds: 10)}); final Duration pingInterval; final Map headers; + final Duration timeout; } Transport protobufTransportBuilder( @@ -47,6 +49,7 @@ abstract class GeneratedMessageSender { Future sendMessage( Req request, Rep result); + Future sendAsyncMessage(Req request); } class Transport implements GeneratedMessageSender { @@ -76,27 +79,53 @@ class Transport implements GeneratedMessageSender { int _messageId = 1; - final _completers = >{}; + var _completers = >{}; @override Future sendMessage( Req request, Rep result) async { - final command = _createCommand(request); - final reply = await _sendCommand(command); + final command = _createCommand(request, false); + try { + var fut = _sendCommand(command); + if (_config.timeout.inMicroseconds > 0) { + fut = fut.timeout(_config.timeout); + } + final reply = await fut; + final filledResult = _processResult(result, reply); + return filledResult; + } on TimeoutException { + if (command.id > 0) { + _completers.remove(command.id); + } + rethrow; + } + } - final filledResult = _processResult(result, reply); - return filledResult; + @override + Future sendAsyncMessage( + Req request) async { + if (_socket == null) { + throw centrifuge.ClientDisconnectedError; + } + final command = _createCommand(request, true); + final List data = _commandEncoder.convert(command); + _socket!.add(data); } Future? close() { - return _socket!.close(); + return _socket?.close(); } - Command _createCommand(GeneratedMessage request) => Command() - ..id = _messageId++ - ..method = _getType(request) - ..params = request.writeToBuffer(); + Command _createCommand(GeneratedMessage request, bool isAsync) { + final cmd = Command() + ..method = _getType(request) + ..params = request.writeToBuffer(); + if (!isAsync) { + cmd.id = _messageId++; + } + return cmd; + } Future _sendCommand(Command command) { final completer = Completer.sync(); @@ -134,6 +163,10 @@ class Transport implements GeneratedMessageSender { return Command_MethodType.SUBSCRIBE; case HistoryRequest: return Command_MethodType.HISTORY; + case PresenceRequest: + return Command_MethodType.PRESENCE; + case PresenceStatsRequest: + return Command_MethodType.PRESENCE_STATS; case RPCRequest: return Command_MethodType.RPC; default: @@ -143,8 +176,12 @@ class Transport implements GeneratedMessageSender { Function _onDone(void Function(String, bool)? onDone) { return () { - String reason = ""; + String reason = "connection closed"; bool reconnect = true; + _completers.forEach((key, value) { + _completers[key]?.completeError(centrifuge.ClientDisconnectedError); + }); + _completers = >{}; if (_socket!.closeReason != null) { try { final Map info = jsonDecode(_socket!.closeReason!); @@ -161,7 +198,7 @@ class Transport implements GeneratedMessageSender { final List replies = _replyDecoder.convert(input); replies.forEach((reply) { if (reply.id > 0) { - _completers.remove(reply.id)!.complete(reply); + _completers.remove(reply.id)?.complete(reply); } else { final push = Push.fromBuffer(reply.result); diff --git a/test/client_test.dart b/test/client_test.dart index 475d463..526b18a 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -138,14 +138,6 @@ void main() { }); test('socket closing triggers the corresponding events', () async { - subscription('test one').subscribe(); - - final unsubscribeOneFuture = - subscription('test one').unsubscribeStream.first; - - final unsubscribeTwoFuture = - subscription('test two').unsubscribeStream.first; - final disconnectFuture = client.disconnectStream.first; transport.onDone!('test reason', true); @@ -154,30 +146,17 @@ void main() { expect(disconnect.reason, equals('test reason')); expect(disconnect.shouldReconnect, isTrue); - - expect(unsubscribeOneFuture, completion(isNotNull)); - expect(unsubscribeTwoFuture, doesNotComplete); }); test('socket error triggers the corresponding events', () async { - subscription('test one').subscribe(); - - final unsubscribeOneFuture = - subscription('test one').unsubscribeStream.first; - final unsubscribeTwoFuture = - subscription('test two').unsubscribeStream.first; - final disconnectFuture = client.disconnectStream.first; transport.onError!('test error'); final disconnect = await disconnectFuture; - expect(disconnect.reason, equals('test error')); + expect(disconnect.reason, equals('connection closed')); expect(disconnect.shouldReconnect, isTrue); - - expect(unsubscribeOneFuture, completion(isNotNull)); - expect(unsubscribeTwoFuture, doesNotComplete); }); test('client does not reconnect if reconnect = false', () async { @@ -250,7 +229,7 @@ void main() { }); test( - 'client reconnect sends diconnect and unsubscribe events only once for the first error', + 'client reconnect sends disconnect and unsubscribe events only once for the first error', () async { var countOneChannelSubscribe = 0; var countOneChannelUnsubscribe = 0; @@ -295,7 +274,7 @@ void main() { expect(countClientDisconnect, 1); expect(countOneChannelSubscribe, 0); - expect(countOneChannelUnsubscribe, 1); + expect(countOneChannelUnsubscribe, 0); expect(countTwoChannelSubscribe, 0); expect(countTwoChannelUnsubscribe, 0); @@ -363,7 +342,7 @@ void main() { expect(countClientDisconnect, 20); expect(countOneChannelSubscribe, 20); - expect(countOneChannelUnsubscribe, 20); + expect(countOneChannelUnsubscribe, 19); expect(countTwoChannelSubscribe, 0); expect(countTwoChannelUnsubscribe, 0); diff --git a/test/src/utils.dart b/test/src/utils.dart index 758010f..d0cda1f 100644 --- a/test/src/utils.dart +++ b/test/src/utils.dart @@ -8,9 +8,9 @@ import 'package:centrifuge/src/proto/client.pb.dart' hide Error, HistoryResult, StreamPosition; import 'package:centrifuge/src/proto/client.pb.dart' as proto; import 'package:centrifuge/src/transport.dart'; +import 'package:fixnum/fixnum.dart' as $fixnum; import 'package:mockito/mockito.dart'; import 'package:protobuf/protobuf.dart'; -import 'package:fixnum/fixnum.dart' as $fixnum; class MockWebSocket implements WebSocket { final List commands = []; @@ -159,6 +159,12 @@ class MockTransport implements Transport { return completer.future; } + + @override + Future sendAsyncMessage(Req request) { + // TODO: implement sendAsyncMessage + throw UnimplementedError(); + } } class Triplet { @@ -208,6 +214,22 @@ class MockClient extends Mock implements ClientImpl { returnValue: Future.value(result)); } + @override + Future sendSubscribe(String channel, String? token) { + return super.noSuchMethod( + Invocation.method(#sendSubscribe, [channel, token]), + returnValue: Future.value(proto.SubscribeResult())); + } + + @override + Future sendUnsubscribe(String channel) { + return super.noSuchMethod( + Invocation.method(#sendUnsubscribe, [ + channel, + ]), + returnValue: Future.value(proto.UnsubscribeResult())); + } + @override Future history(String channel, {int limit = 0, StreamPosition? since, bool reverse = false}) { diff --git a/test/subscription_test.dart b/test/subscription_test.dart index 24c7298..720e4af 100644 --- a/test/subscription_test.dart +++ b/test/subscription_test.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'dart:convert'; +import 'package:centrifuge/src/events.dart'; import 'package:centrifuge/src/proto/client.pb.dart' as protocol; import 'package:centrifuge/src/subscription.dart'; -import 'package:centrifuge/src/events.dart'; +import 'package:fixnum/fixnum.dart' as $fixnum; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; -import 'package:fixnum/fixnum.dart' as $fixnum; import 'src/utils.dart'; @@ -28,11 +28,9 @@ void main() { when(client.getToken(channel)).thenAnswer((_) => Future.value(token)); when( - client.sendMessage( - protocol.SubscribeRequest() - ..channel = channel - ..token = token, - protocol.SubscribeResult(), + client.sendSubscribe( + channel, + token, ), ).thenAnswer((_) async => protocol.SubscribeResult()..recovered = true); @@ -45,58 +43,55 @@ void main() { }); test('subscription resubscribes if was subscribed', () async { - final subscribeSuccess = () => subscription.subscribeSuccessStream.first; - final request = protocol.SubscribeRequest() - ..channel = channel - ..token = token; - final result = protocol.SubscribeResult(); + var numOnSubscribeSuccessCalls = 0; + var completer = new Completer(); + final onSubscriptionEvent = (dynamic event) { + numOnSubscribeSuccessCalls++; + completer.complete(); + completer = new Completer(); + }; + subscription.subscribeSuccessStream.listen(onSubscriptionEvent); when(client.getToken(channel)).thenAnswer((_) => Future.value(token)); when( - client.sendMessage(request, result), + client.sendSubscribe(channel, token), ).thenAnswer((_) async => protocol.SubscribeResult()..recovered = true); - subscription.subscribe(); - - await subscribeSuccess(); - - subscription.resubscribeIfNeeded(); + when( + client.sendUnsubscribe(channel), + ).thenAnswer((_) async => protocol.UnsubscribeResult()); - await subscribeSuccess(); + await subscription.subscribe(); + await completer.future; + await subscription.unsubscribe(); + await subscription.subscribe(); + await completer.future; - verify(client - .sendMessage( - request, result)) - .called(2); + expect(numOnSubscribeSuccessCalls, 2); }); test('subscription doesn\'t resubscribe if wasn\'t subscribed', () async { - subscription.resubscribeIfNeeded(); + subscription.resubscribeOnConnect(); verifyNoMoreInteractions(client); }); - test('subscription unsubscribes if wasn subscribed', () async { + test('subscription unsubscribes if was not subscribed', () async { final subscribeSuccess = subscription.subscribeSuccessStream.first; final unsubscribe = subscription.unsubscribeStream.first; when(client.getToken(channel)).thenAnswer((_) => Future.value(token)); when( - client.sendMessage( - protocol.SubscribeRequest() - ..channel = channel - ..token = token, - protocol.SubscribeResult(), + client.sendSubscribe( + channel, + token, ), ).thenAnswer((_) async => protocol.SubscribeResult()..recovered = true); when( - client.sendMessage( - protocol.UnsubscribeRequest()..channel = channel, - protocol.UnsubscribeResult(), - ), + client.sendUnsubscribe(channel), ).thenAnswer((_) async => protocol.UnsubscribeResult()); subscription.subscribe(); @@ -114,19 +109,19 @@ void main() { verifyNoMoreInteractions(client); }); - test('subscription sends event if was subscribed', () async { + test('subscription doesn\'t send event if was subscribing', () async { final unsubscribe = subscription.unsubscribeStream.first; subscription.subscribe(); - subscription.sendUnsubscribeEventIfNeeded(); + subscription.unsubscribeOnDisconnect(); - expect(unsubscribe, completion(isNotNull)); + expect(unsubscribe, doesNotComplete); }); test('subscription doesn\'t send event if wasn\'t subscribed', () async { final unsubscribe = subscription.unsubscribeStream.first; - subscription.sendUnsubscribeEventIfNeeded(); + subscription.unsubscribeOnDisconnect(); expect(unsubscribe, doesNotComplete); }); @@ -137,11 +132,9 @@ void main() { when(client.getToken(channel)).thenAnswer((_) => Future.value(token)); when( - client.sendMessage( - protocol.SubscribeRequest() - ..channel = channel - ..token = token, - protocol.SubscribeResult(), + client.sendSubscribe( + channel, + token, ), ).thenAnswer( (_) async => protocol.SubscribeResult() @@ -169,11 +162,9 @@ void main() { subscription.subscribe(); when( - client.sendMessage( - protocol.SubscribeRequest() - ..channel = channel - ..token = token, - protocol.SubscribeResult(), + client.sendSubscribe( + channel, + token, ), ).thenAnswer((_) => Future.error('test error')); diff --git a/test/transport_test.dart b/test/transport_test.dart index 605d089..d4fbff1 100644 --- a/test/transport_test.dart +++ b/test/transport_test.dart @@ -126,7 +126,7 @@ void main() { webSocket.close(3001, '{"reason":'); - expect(reason, isEmpty); + expect(reason, 'connection closed'); expect(reconnect, isTrue); }); @@ -142,7 +142,7 @@ void main() { webSocket.close(3001, '{}'); - expect(reason, isEmpty); + expect(reason, 'connection closed'); expect(reconnect, isTrue); }); @@ -157,7 +157,7 @@ void main() { webSocket.close(3001, null); - expect(reason, isEmpty); + expect(reason, 'connection closed'); expect(reconnect, isTrue); }); });