From 3f0084cc0796aebaf8ad897368cc702010d96a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:36:06 +0000 Subject: [PATCH 1/5] Support latest Dart/Flutter and Meteor 3.5.1 - Bump minimum SDK to Dart 3.6, tested with Dart 3.13 (latest stable). - Release 4.0.0 including web support from the 4.0.0 betas. - Update dependency floors and lints to lints 6; fix all analyzer issues. - Rename DdpClient.PING_SEC_INTERVAL/PONG_WITHIN_SEC to static constants pingIntervalSeconds/pongTimeoutSeconds (breaking). - Add a standalone DDP protocol test suite backed by an in-process mock server speaking DDP version 1 as Meteor 3.5.1 does (handshake, ping/pong, methods, EJSON $date, SHA-256 login, subscriptions, reconnect) so core behavior is testable without docker or a live Meteor server. - Exclude the Flutter example from root package analysis and mark it publish_to: none; bump example to flutter_lints 6. - Modernize CI: dart-lang/setup-dart, analyze + protocol tests before the docker-based integration tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N1hm1DRToNVwU4D6wtJAt3 --- .github/workflows/dart.yml | 30 ++- CHANGELOG.md | 8 + README.md | 3 + analysis_options.yaml | 6 +- example/pubspec.yaml | 3 +- lib/dart_meteor.dart | 2 +- lib/src/ddp_client.dart | 9 +- lib/src/meteor_client.dart | 2 +- pubspec.yaml | 12 +- test/dart_meteor_test.dart | 3 +- test/ddp_mock_server_test.dart | 430 +++++++++++++++++++++++++++++++++ 11 files changed, 474 insertions(+), 34 deletions(-) create mode 100644 test/ddp_mock_server_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index b36474f..38535e6 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -7,30 +7,28 @@ jobs: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Install Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - name: Install dependencies + run: dart pub get --no-example + - name: Analyze + run: dart analyze + - name: Run DDP protocol tests (no server required) + run: dart test test/ddp_mock_server_test.dart - name: Prepare docker network run: docker network create test_net - name: Start MongoDB run: docker run --rm --name mongodb --network test_net -d mongo - - run: docker ps - run: sleep 5 - - run: docker ps - name: Start webapp - run: docker run --rm --name webapp --network test_net -p 3000:3000 -e "MONGO_URL=mongodb://mongodb:27017/meteor" -e "ROOT_URL=http://webapp" -d tanutapi/simple-meteor-chat:latest + run: docker run --rm --name webapp --network test_net -p 3000:3000 -e "MONGO_URL=mongodb://mongodb:27017/meteor" -e "ROOT_URL=http://webapp" -d tanutapi/simple-meteor-chat:latest - run: sleep 30 - run: docker ps - run: docker logs webapp - - run: sudo apt-get update - - run: sudo apt-get install apt-transport-https curl -y - name: Check webapp run: curl 127.0.0.1:3000 - - run: sudo sh -c 'wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -' - - run: sudo sh -c 'wget -qO- https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list' - - run: sudo apt-get update - - name: Install dart - run: sudo apt-get install dart -y - - run: export PATH="$PATH:/usr/lib/dart/bin" - - uses: actions/checkout@v1 - - name: Install dependencies - run: rm -rf ./example && PATH="$PATH:/usr/lib/dart/bin" dart pub get - - name: Run tests - run: PATH="$PATH:/usr/lib/dart/bin" dart run test + - name: Run integration tests + run: dart test test/dart_meteor_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d8eef3e..8f894c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 4.0.0 +- Support for the latest Dart/Flutter releases (tested with Dart 3.13). The minimum SDK is now Dart 3.6. +- Verified compatibility with Meteor 3.x servers, including Meteor 3.5.1 (DDP protocol version 1, SHA-256 password login, EJSON `$date` handling). +- Web platform support from the 4.0.0 betas via `web_socket_channel` is included. +- Added a standalone DDP protocol test suite (`test/ddp_mock_server_test.dart`) that runs without a Meteor server or docker. +- Updated dependencies and lint rules (`lints` 6). +- BREAKING: `DdpClient.PING_SEC_INTERVAL` and `DdpClient.PONG_WITHIN_SEC` were renamed to the static constants `DdpClient.pingIntervalSeconds` and `DdpClient.pongTimeoutSeconds`. + # 4.0.0-beta.1, 4.0.0-beta.2 - Adding Web platform support by using the `web_socket_channel`. diff --git a/README.md b/README.md index 1e47960..78730c1 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ For Dart VM, Flutter iOS/Android/Web (master branch) ![](https://github.com/tanu This library connects the Meteor backend and the Flutter app—designed to work seamlessly with StreamBuilder and FutureBuilder. +## Change on 4.0.0 +Support for the latest Dart/Flutter releases (Dart 3.6+) and Meteor 3.x servers, including Meteor 3.5.1. + ## Change on 4.0.0-beta.1 Using the `web_socket_channel` to make this package supports Dart VM, iOS, Android, and Web. Thank you to mel-mouk. diff --git a/analysis_options.yaml b/analysis_options.yaml index 2999f87..9812fc2 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,5 +10,7 @@ include: package:lints/recommended.yaml # - camel_case_types analyzer: -# exclude: -# - path/to/excluded/files/** + exclude: + # The example is a Flutter app with its own analysis_options.yaml and + # requires the Flutter SDK; exclude it from the pure-Dart package analysis. + - example/** diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6fff8d5..665af23 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,5 +1,6 @@ name: dart_meteor_example_app description: A new Flutter project. +publish_to: none # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -36,7 +37,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 # For information on the generic Dart part of this file, see the diff --git a/lib/dart_meteor.dart b/lib/dart_meteor.dart index 140e414..ba630ab 100644 --- a/lib/dart_meteor.dart +++ b/lib/dart_meteor.dart @@ -1,6 +1,6 @@ /// Meteor connection for dart /// This library is a meteor client warpper -library dart_meteor; +library; export 'src/meteor_client.dart'; export 'src/ddp_client.dart'; diff --git a/lib/src/ddp_client.dart b/lib/src/ddp_client.dart index a04ade7..37cc9f9 100644 --- a/lib/src/ddp_client.dart +++ b/lib/src/ddp_client.dart @@ -79,8 +79,8 @@ class OnReconnectionCallback { } class DdpClient { - final int PING_SEC_INTERVAL = 20; - final int PONG_WITHIN_SEC = 5; + static const int pingIntervalSeconds = 20; + static const int pongTimeoutSeconds = 5; final Random _random = Random.secure(); final StreamController _statusStreamController = @@ -316,7 +316,7 @@ class DdpClient { _socket!.sink.add(msg); var sentTime = DateTime.now(); _flagToBeResetAtPongMsg = true; - Future.delayed(Duration(seconds: PONG_WITHIN_SEC), () { + Future.delayed(Duration(seconds: pongTimeoutSeconds), () { if (_flagToBeResetAtPongMsg == true) { printDebug(''); printDebug('Disconnect due to not receiving PONG'); @@ -411,7 +411,7 @@ class DdpClient { } _pingPeriodicTimer = - Timer.periodic(Duration(seconds: PING_SEC_INTERVAL), (timer) { + Timer.periodic(Duration(seconds: pingIntervalSeconds), (timer) { _sendMsgPing(); }); } else if (msg == 'failed') { @@ -532,7 +532,6 @@ class DdpClient { } else if (k == '\$date') { if (parent != null && field != null) { parent[field] = DateTime.fromMillisecondsSinceEpoch(v); - return parent[field]; } } }); diff --git a/lib/src/meteor_client.dart b/lib/src/meteor_client.dart index b120d02..ace0554 100644 --- a/lib/src/meteor_client.dart +++ b/lib/src/meteor_client.dart @@ -92,7 +92,7 @@ class MeteorClient { MeteorClient.connect( {required String url, bool debug = false, - userAgent = 'DartMeteor/2.0.4'}) { + userAgent = 'DartMeteor/4.0.0'}) { url = url.replaceFirst(RegExp(r'^http'), 'ws'); if (!url.endsWith('websocket')) { url = '${url.replaceFirst(RegExp(r'/$'), '')}/websocket'; diff --git a/pubspec.yaml b/pubspec.yaml index 24f8a5b..f1a3c54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,17 +1,17 @@ name: dart_meteor description: This library make connection between meteor backend and flutter app easily. Design to work seamlessly with StreamBuilder and FutureBuilder. -version: 3.1.0 +version: 4.0.0 homepage: https://github.com/tanutapi/dart_meteor environment: - sdk: '>=2.12.0 <4.0.0' + sdk: '>=3.6.0 <4.0.0' dependencies: - crypto: ^3.0.2 + crypto: ^3.0.6 rxdart: ^0.28.0 web_socket_channel: ^3.0.3 dev_dependencies: - lints: ^6.0.0 - test: ^1.17.4 - collection: ^1.15.0 + lints: ^6.1.0 + test: ^1.26.3 + collection: ^1.19.0 diff --git a/test/dart_meteor_test.dart b/test/dart_meteor_test.dart index 2cd0a46..970c96a 100644 --- a/test/dart_meteor_test.dart +++ b/test/dart_meteor_test.dart @@ -475,10 +475,9 @@ void main() { expect(completer.future, completion(true)); await meteor.loginWithPassword('user1', 'password1'); var reactive = BehaviorSubject(); - SubscriptionHandler sub; reactive.add('user1'); reactive.listen((username) { - sub = meteor.subscribe('assets', args: [username], onReady: () async { + meteor.subscribe('assets', args: [username], onReady: () async { await Future.delayed(Duration(seconds: 2)); var assets = meteor.collectionCurrentValue('assets'); if (username == 'user2' && assets!.length == 2) { diff --git a/test/ddp_mock_server_test.dart b/test/ddp_mock_server_test.dart new file mode 100644 index 0000000..cc58148 --- /dev/null +++ b/test/ddp_mock_server_test.dart @@ -0,0 +1,430 @@ +/// Tests the MeteorClient/DdpClient against a local mock server that +/// implements the DDP protocol (version "1") exactly as a Meteor 3.x server +/// (tested against the behavior of Meteor 3.5.1) speaks it over +/// `/websocket`. These tests run standalone - no Meteor server or docker +/// required. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dart_meteor/dart_meteor.dart'; +import 'package:test/test.dart'; + +/// A minimal in-process DDP server speaking protocol version "1". +class MockDdpServer { + HttpServer? _httpServer; + final List _sockets = []; + int get port => _httpServer!.port; + + /// Documents published by the `items` publication. + final Map> itemsCollection = {}; + + /// Digest expected from loginWithPassword for user1/password1. + static final String user1Digest = + sha256.convert(utf8.encode('password1')).toString(); + + /// The last login request received, for asserting on the wire format. + Map? lastLoginRequest; + + /// The last method message received, for asserting on the wire format. + Map? lastMethodMessage; + + Future start() async { + _httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _httpServer!.listen((HttpRequest req) async { + if (WebSocketTransformer.isUpgradeRequest(req)) { + var socket = await WebSocketTransformer.upgrade(req); + _sockets.add(socket); + // Meteor sends server_id as the very first message on the socket. + socket.add(json.encode({'server_id': '0'})); + socket.listen((data) => _onMessage(socket, data), + onDone: () => _sockets.remove(socket)); + } else { + req.response.statusCode = HttpStatus.notFound; + await req.response.close(); + } + }); + } + + Future stop() async { + for (var socket in List.from(_sockets)) { + await socket.close(); + } + _sockets.clear(); + await _httpServer?.close(force: true); + _httpServer = null; + } + + void closeAllSockets() { + for (var socket in List.from(_sockets)) { + socket.close(); + } + _sockets.clear(); + } + + void _send(WebSocket socket, Map msg) { + socket.add(json.encode(msg)); + } + + void _onMessage(WebSocket socket, dynamic data) { + var msg = json.decode(data) as Map; + switch (msg['msg']) { + case 'connect': + if ((msg['version'] == '1') && (msg['support'] as List).contains('1')) { + _send(socket, {'msg': 'connected', 'session': 'mock-session-id'}); + } else { + _send(socket, {'msg': 'failed', 'version': '1'}); + } + break; + case 'ping': + _send(socket, {'msg': 'pong', if (msg['id'] != null) 'id': msg['id']}); + break; + case 'pong': + break; + case 'method': + _handleMethod(socket, msg); + break; + case 'sub': + _handleSub(socket, msg); + break; + case 'unsub': + _send(socket, {'msg': 'nosub', 'id': msg['id']}); + break; + } + } + + void _handleMethod(WebSocket socket, Map msg) { + lastMethodMessage = msg; + var id = msg['id']; + var params = msg['params'] as List? ?? []; + switch (msg['method']) { + case 'login': + var loginData = params.isNotEmpty + ? params[0] as Map + : {}; + lastLoginRequest = loginData; + var password = loginData['password']; + var resume = loginData['resume']; + var validPassword = password is Map && + password['algorithm'] == 'sha-256' && + password['digest'] == user1Digest; + var validResume = resume == 'valid-resume-token'; + if (validPassword || validResume) { + _send(socket, { + 'msg': 'result', + 'id': id, + 'result': { + 'id': 'user1-id', + 'token': 'valid-resume-token', + 'tokenExpires': { + '\$date': DateTime.now() + .add(Duration(days: 90)) + .millisecondsSinceEpoch + }, + }, + }); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + } else { + _send(socket, { + 'msg': 'result', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 403, + 'reason': 'Incorrect password', + 'message': 'Incorrect password [403]', + 'errorType': 'Meteor.Error', + }, + }); + } + break; + case 'echo': + _send(socket, {'msg': 'result', 'id': id, 'result': params}); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + break; + case 'methodThatReturnNumber': + _send(socket, {'msg': 'result', 'id': id, 'result': 42}); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + break; + case 'methodThatReturnDate': + _send(socket, { + 'msg': 'result', + 'id': id, + 'result': { + 'createdAt': {'\$date': 1598804210504}, + }, + }); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + break; + case 'methodThatThrowError': + _send(socket, { + 'msg': 'result', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 500, + 'reason': 'This is an error', + 'message': 'This is an error [500]', + 'errorType': 'Meteor.Error', + }, + }); + break; + default: + _send(socket, { + 'msg': 'result', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 404, + 'reason': "Method '${msg['method']}' not found", + 'errorType': 'Meteor.Error', + }, + }); + } + } + + void _handleSub(WebSocket socket, Map msg) { + var id = msg['id']; + switch (msg['name']) { + case 'items': + itemsCollection.forEach((docId, fields) { + _send(socket, { + 'msg': 'added', + 'collection': 'items', + 'id': docId, + 'fields': fields, + }); + }); + _send(socket, { + 'msg': 'ready', + 'subs': [id] + }); + break; + default: + _send(socket, { + 'msg': 'nosub', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 404, + 'reason': "Subscription '${msg['name']}' not found", + 'errorType': 'Meteor.Error', + }, + }); + } + } + + /// Push a change on the `items` collection to every connected client. + void broadcast(Map msg) { + for (var socket in _sockets) { + _send(socket, msg); + } + } +} + +Future _waitForConnected(MeteorClient meteor) async { + await meteor + .status() + .firstWhere((s) => s.status == DdpConnectionStatusValues.connected) + .timeout(Duration(seconds: 10)); +} + +void main() { + group('DDP protocol against a mock Meteor 3.x server', () { + late MockDdpServer server; + late MeteorClient meteor; + + setUp(() async { + server = MockDdpServer(); + server.itemsCollection['doc1'] = { + 'title': 'First', + 'createdAt': {'\$date': 1598804210504}, + }; + await server.start(); + meteor = MeteorClient.connect(url: 'ws://127.0.0.1:${server.port}'); + await _waitForConnected(meteor); + }); + + tearDown(() async { + meteor.disconnect(); + await server.stop(); + }); + + test('completes the version 1 handshake and exposes ids', () async { + expect(meteor.connection.serverId, '0'); + expect(meteor.connection.sessionId, 'mock-session-id'); + }); + + test('method call returns the result', () async { + var result = await meteor.call('methodThatReturnNumber'); + expect(result, 42); + }); + + test('method call echoes arguments and DateTime is EJSON encoded', + () async { + var date = DateTime.fromMillisecondsSinceEpoch(1598804210504); + await meteor.call('echo', args: ['hello', 1, date]); + var sentParams = server.lastMethodMessage!['params'] as List; + expect(sentParams[0], 'hello'); + expect(sentParams[1], 1); + expect(sentParams[2], {'\$date': 1598804210504}); + }); + + test('EJSON \$date in results is decoded to DateTime', () async { + var result = await meteor.call('methodThatReturnDate'); + expect(result['createdAt'], isA()); + expect((result['createdAt'] as DateTime).millisecondsSinceEpoch, + 1598804210504); + }); + + test('method errors surface as MeteorError', () async { + try { + await meteor.call('methodThatThrowError'); + fail('expected MeteorError'); + } on MeteorError catch (e) { + expect(e.error, 500); + expect(e.reason, 'This is an error'); + expect(e.errorType, 'Meteor.Error'); + } + }); + + test('loginWithPassword sends a sha-256 digest and yields a token', + () async { + var result = await meteor.loginWithPassword('user1', 'password1'); + expect(result.userId, 'user1-id'); + expect(result.token, 'valid-resume-token'); + expect(result.tokenExpires.isAfter(DateTime.now()), isTrue); + + var password = server.lastLoginRequest!['password']; + expect(password['algorithm'], 'sha-256'); + expect(password['digest'], MockDdpServer.user1Digest); + // The password must never be sent in plain text. + expect( + json.encode(server.lastLoginRequest), isNot(contains('password1'))); + }); + + test('bad login rejects with MeteorError 403', () async { + try { + await meteor.loginWithPassword('user1', 'wrong-password'); + fail('expected MeteorError'); + } on MeteorError catch (e) { + expect(e.error, 403); + expect(e.reason, 'Incorrect password'); + } + }); + + test('loginWithToken resumes the session', () async { + var result = await meteor.loginWithToken(token: 'valid-resume-token'); + expect(result, isNotNull); + expect(result!.userId, 'user1-id'); + expect(server.lastLoginRequest, {'resume': 'valid-resume-token'}); + }); + + test('subscription becomes ready and documents arrive in the collection', + () async { + var handler = meteor.subscribe('items'); + await handler.ready().firstWhere((ready) => ready == true).timeout( + Duration(seconds: 5), + ); + var items = await meteor + .collection('items') + .firstWhere((c) => c.isNotEmpty) + .timeout(Duration(seconds: 5)); + expect(items['doc1'], isNotNull); + expect(items['doc1']['title'], 'First'); + expect(items['doc1']['createdAt'], isA()); + }); + + test('unknown subscription reports nosub through onStop', () async { + var completer = Completer(); + meteor.subscribe('doesNotExist', onStop: (error) { + completer.complete(error); + return () {}; + }); + var error = await completer.future.timeout(Duration(seconds: 5)); + expect(error, isNotNull); + expect(error['error'], 404); + }); + + test('changed and removed messages update the collection stream', () async { + var handler = meteor.subscribe('items'); + await handler.ready().firstWhere((ready) => ready == true).timeout( + Duration(seconds: 5), + ); + await meteor + .collection('items') + .firstWhere((c) => c.isNotEmpty) + .timeout(Duration(seconds: 5)); + + server.broadcast({ + 'msg': 'changed', + 'collection': 'items', + 'id': 'doc1', + 'fields': {'title': 'Updated'}, + }); + var updated = await meteor + .collection('items') + .firstWhere((c) => c['doc1']?['title'] == 'Updated') + .timeout(Duration(seconds: 5)); + expect(updated['doc1']['title'], 'Updated'); + + server.broadcast({ + 'msg': 'removed', + 'collection': 'items', + 'id': 'doc1', + }); + var afterRemove = await meteor + .collection('items') + .firstWhere((c) => c['doc1'] == null) + .timeout(Duration(seconds: 5)); + expect(afterRemove.containsKey('doc1'), isFalse); + }); + + test('server-initiated ping is answered so the connection stays up', + () async { + server.broadcast({'msg': 'ping'}); + // If the client failed to pong, nothing observable happens locally; + // simply assert the connection is still healthy after a beat. + await Future.delayed(Duration(milliseconds: 500)); + var result = await meteor.call('methodThatReturnNumber'); + expect(result, 42); + }); + + test('client reconnects and re-subscribes after the socket drops', + () async { + var handler = meteor.subscribe('items'); + await handler.ready().firstWhere((ready) => ready == true).timeout( + Duration(seconds: 5), + ); + + server.closeAllSockets(); + await meteor + .status() + .firstWhere((s) => s.status != DdpConnectionStatusValues.connected) + .timeout(Duration(seconds: 10)); + await _waitForConnected(meteor); + + // The subscription must have been re-sent on the new socket. + var items = await meteor + .collection('items') + .firstWhere((c) => c.isNotEmpty) + .timeout(Duration(seconds: 10)); + expect(items['doc1'], isNotNull); + }, timeout: Timeout(Duration(seconds: 30))); + }); +} From 7965fdfd3896fa3c06de1b6943aa6a3575bbf6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:40:50 +0000 Subject: [PATCH 2/5] Rewrite README for clarity Restructure into installation, quick start, methods, subscriptions, accounts, connection management, and upgrade notes. Modernize code samples (null safety, ElevatedButton, async/await error handling) and fold the old per-version change notes into a compact Upgrading section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N1hm1DRToNVwU4D6wtJAt3 --- README.md | 381 +++++++++++++++++++++++++++--------------------------- 1 file changed, 191 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index 78730c1..868f187 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,88 @@ -For Dart VM, Flutter iOS/Android/Web (master branch) ![](https://github.com/tanutapi/dart_meteor/workflows/Testing/badge.svg?branch=master) +# dart_meteor — a Meteor DDP client for Dart/Flutter -# A Meteor DDP library for Dart/Flutter developers. +![](https://github.com/tanutapi/dart_meteor/workflows/Testing/badge.svg?branch=master) -This library connects the Meteor backend and the Flutter app—designed to work seamlessly with StreamBuilder and FutureBuilder. +Connect your Flutter app to a [Meteor](https://www.meteor.com/) backend over DDP. +Designed to work seamlessly with `StreamBuilder` and `FutureBuilder`. -## Change on 4.0.0 -Support for the latest Dart/Flutter releases (Dart 3.6+) and Meteor 3.x servers, including Meteor 3.5.1. +- **Platforms:** Dart VM, Flutter iOS/Android/Web +- **Dart:** 3.6 or newer +- **Meteor:** compatible with Meteor 2.x and 3.x servers (tested against Meteor 3.5.1) -## Change on 4.0.0-beta.1 -Using the `web_socket_channel` to make this package supports Dart VM, iOS, Android, and Web. Thank you to mel-mouk. +## Features -## Change on 3.1.0 ## -Bump the SDK version to <4.0.0 and update dependencies. +- Method calls with `Future`-based results +- Subscriptions and reactive collections as `Stream`s +- Accounts: login with password/token, logout, password management +- Automatic reconnection with re-login and re-subscription +- `DateTime` values are converted to/from EJSON `$date` automatically -## Change on 3.0.0 ## -BREAKING CHANGE. The `meteor.collection('collectionName')` streams are now `snapshot.hasData == true` and have an empty map at the beginning. +## Installation -## Change on 2.0.0 ## +Add the package to your `pubspec.yaml`: -Passing arguments to the meteor method is now optional. In version 1.x.x you did: `meteor.call('your_method_name', [param1, param2])`. Now in version 2.x.x and greater, it will be `meteor.call('your_method_name', args: [param1, param2])` or just `meteor.call('your_method_name')` if you don't want to pass any argument to your method. - -Same as a subscription. In version 1.x.x you did: `meteor.subscribe('your_pub', [param1, param2])`. Now in version 2.x.x and greater, it will be `meteor.subscribe('your_pub', args: [param1, param2])` or just `meteor.subscribe('your_pub')` if you don't want to pass any argument to your publish function. - -In version 1.x.x, you have to call `meteor.prepareCollection('your_collection_name')` before you can use it. Now in version 2.x.x, you don't have to prepare a collection. You now access the collection by calling `collection` method `meteor.collection('messages').listen((value) { ... })`. - -`DateTime` is now directly supported. You can pass a `DateTime` variable as a meteor method parameter and receive DateTime from the collections and methods. - -## Usage - -I have published a post on Medium showing how to handle connection status, user authentication, and subscriptions. Please check https://medium.com/@tanutapi/writing-flutter-mobile-application-with-meteor-backend-643d2c1947d0?source=friends_link&sk=52ce2fa2603934e7395e2d19dd54e06c +```yaml +dependencies: + dart_meteor: ^4.0.0 +``` -A simple usage example: +## Quick start -First, create an instance of MeteorClient in your app's global scope to use it anywhere in your project. +Create a single `MeteorClient` instance in your app's global scope so you can +use it anywhere in your project. The client connects immediately and keeps the +connection alive: ```dart import 'package:flutter/material.dart'; import 'package:dart_meteor/dart_meteor.dart'; -MeteorClient meteor = MeteorClient.connect(url: 'https://yourdomain.com'); +final meteor = MeteorClient.connect(url: 'https://yourdomain.com'); + void main() => runApp(MyApp()); ``` -In your StatefulWidget/StatelessWidget, thanks to [rxdart][rxdart], you can use FutuerBuilder or StreamBuilder to build your widget based on a response from meteor's DDP server. +The `url` may be `https://…` or `wss://…`; the client appends the `/websocket` +DDP endpoint for you. -```dart -class MyApp extends StatefulWidget { - @override - _MyAppState createState() => _MyAppState(); -} +Then build widgets from the client's streams: -class _MyAppState extends State { - String _methodResult = ''; - - void _callMethod() { - meteor.call('helloMethod').then((result) { - setState(() { - _methodResult = result.toString(); - }); - }).catchError((err) { - if (err is MeteorError) { - setState(() { - _methodResult = err.message; - }); - } - }); - } +```dart +class MyApp extends StatelessWidget { + const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( - appBar: AppBar( - title: Text('Package dart_meteor Example'), - ), - body: Container( - padding: EdgeInsets.all(8.0), - child: Column( - children: [ - StreamBuilder( - stream: meteor.status(), - builder: (context, snapshot) { - if (snapshot.hasData) { - if (snapshot.data.status == - DdpConnectionStatusValues.connected) { - return RaisedButton( - child: Text('Disconnect'), - onPressed: () { - meteor.disconnect(); - }, - ); - } - return RaisedButton( - child: Text('Connect'), - onPressed: () { - meteor.reconnect(); - }, - ); - } - return Container(); - }, - ), - StreamBuilder( - stream: meteor.status(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text('Meteor Status ${snapshot.data.toString()}'); - } - return Text('Meteor Status: ---'); - }, - ), - StreamBuilder( - stream: meteor.userId(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return RaisedButton( - child: Text('Logout'), - onPressed: () { - meteor.logout(); - }, - ); - } - return RaisedButton( - child: Text('Login'), - onPressed: () { - meteor.loginWithPassword( - 'yourusername', 'yourpassword'); - }, - ); - }), - StreamBuilder( - stream: meteor.user(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text(snapshot.data.toString()); - } - return Text('User: ----'); - }, - ), - RaisedButton( - child: Text('Method Call'), - onPressed: _callMethod, - ), - Text(_methodResult), - ], - ), + appBar: AppBar(title: const Text('dart_meteor example')), + body: Column( + children: [ + // Show the live connection status. + StreamBuilder( + stream: meteor.status(), + builder: (context, snapshot) { + if (!snapshot.hasData) return const Text('Status: ---'); + return Text('Status: ${snapshot.data}'); + }, + ), + // Show a login/logout button depending on the current user. + StreamBuilder( + stream: meteor.userId(), + builder: (context, snapshot) { + if (snapshot.data != null) { + return ElevatedButton( + onPressed: () => meteor.logout(), + child: const Text('Logout'), + ); + } + return ElevatedButton( + onPressed: () => + meteor.loginWithPassword('username', 'password'), + child: const Text('Login'), + ); + }, + ), + ], ), ), ); @@ -152,83 +90,75 @@ class _MyAppState extends State { } ``` -## Making a method call to your server +A complete runnable app is in [/example][example], and there is a longer +walk-through covering connection status, authentication, and subscriptions in +[this Medium post][medium]. -Making a method call to your server returns a Future. You MUST handle `catchError` to prevent your app from crashing if something goes wrong. +## Method calls + +`meteor.call()` returns a `Future`. Always handle errors — an unhandled +`MeteorError` will otherwise crash your app: ```dart -meteor.call('helloMethod').then((result) { - setState(() { - _methodResult = result.toString(); - }); -}).catchError((err) { - if (err is MeteorError) { - setState(() { - _methodResult = err.message; - }); - } -}); +try { + final result = await meteor.call('sumMethod', args: [5, 10]); + print('Answer is $result'); // 15 +} on MeteorError catch (err) { + print('${err.error}: ${err.reason}'); +} ``` -You can also use it with a FutureBuilder. + +Arguments are optional — `meteor.call('helloMethod')` works too. A `DateTime` +anywhere in the arguments or the result is converted to/from Meteor's EJSON +date format automatically. + +Method calls also fit naturally into a `FutureBuilder`: + ```dart -FutureBuilder( +FutureBuilder( future: meteor.call('sumMethod', args: [5, 10]), builder: (context, snapshot) { - if (snapshot.hasData) { - // your snapshot.data should be 5 + 10 = 15 - return Text('Answer is: ${snapshot.data}'); - } + if (snapshot.hasError) return Text('Error: ${snapshot.error}'); + if (!snapshot.hasData) return const CircularProgressIndicator(); + return Text('Answer is: ${snapshot.data}'); }, ), ``` -You can find an example project inside [/example][example]. +## Subscriptions and collections -## Collections & Subscriptions -You can access your collections by calling `collection('your_collection_name')`. -It will return a `Stream`, which you can use with your `StreamBuilder`. Through the returned `Stream` reference, you can listen to the updates of the collection. - -```dart -meteor.collection('your_collections'); -``` - -The above code will return a stream backed by the rxdart `BehaviorSubject`, a special StreamController that captures the latest item added to the Stream and emits it as the first item to any new listener. You can use it as a regular Stream. - -To make collections available in the Flutter app, you might make a subscription to your server with the following: +Subscribe to a publication on the server, and read the documents it publishes +through `meteor.collection()`: ```dart class YourWidget extends StatefulWidget { - YourWidget() {} + const YourWidget({super.key}); @override - _YourWidgetState createState() => _YourWidgetState(); + State createState() => _YourWidgetState(); } class _YourWidgetState extends State { - SubscriptionHandler _subscriptionHandler; + late SubscriptionHandler _subscription; @override void initState() { super.initState(); - _subscriptionHandler = meteor.subscribe('your_pub', args: ['param_1', 'param_2']); + _subscription = meteor.subscribe('your_pub', args: ['param_1', 'param_2']); } @override void dispose() { - _subscriptionHandler.stop(); + _subscription.stop(); super.dispose(); } @override Widget build(BuildContext context) { - return StreamBuilder( + return StreamBuilder>( stream: meteor.collection('your_collection'), - builder: - (context, AsyncSnapshot> snapshot) { - int docCount = 0; - if (snapshot.hasData) { - docCount = snapshot.data.length; - } + builder: (context, snapshot) { + final docCount = snapshot.data?.length ?? 0; return Text('Total document count: $docCount'); }, ); @@ -236,45 +166,115 @@ class _YourWidgetState extends State { } ``` -The collection was returned as a Map. The key is a document .\_id, and its value is the whole document. +Details worth knowing: -Ex. -``` +- `meteor.subscribe()` returns a `SubscriptionHandler` with `stop()` and a + `ready()` stream that emits `true` once the server has sent the initial + batch of documents. Optional `onReady` and `onStop` callbacks are also + supported. Subscriptions are re-established automatically after a reconnect. +- `meteor.collection()` returns a stream backed by an rxdart + `BehaviorSubject`: every new listener immediately receives the latest value, + so a `StreamBuilder` starts with `snapshot.hasData == true` and an empty map + before any documents arrive. +- The emitted value is a `Map` keyed by document `_id`, with + the whole document as the value: + +```jsonc { "DGbsysgxzSf7Cr8Jg": { - "_id": "DGbsysgxzSf7Cr8Jg", - field1: 0, - field2: "a", - field3: true, - field4: SomeDate + "_id": "DGbsysgxzSf7Cr8Jg", + "field1": 0, + "field2": "a", + "field3": true, + "field4": "2020-08-30T16:15:57.000Z" // delivered as a Dart DateTime } } ``` -We don't provide something like minimongo as the official Meteor did. You can use `reduce`, `map`, and `where` with the collection and get the same result as you did with a query in the `minimongo` `Meteor` web client. -## Don't want to access data via Stream -Getting the current data from a stream is sometimes complicated. Especially when you want to get the latest value just for condition checking, you can access the latest value from the `collection`, `user`, `userId` directly with `meteor.collectionCurrentValue('your_collection_name')`, `meteor.userCurrentValue()`, and `meteor.userIdCurrentValue()`. +There is no minimongo on the client. Use plain Dart collection operations +(`where`, `map`, `reduce`, …) to query the map — they cover the same ground as +minimongo queries in the Meteor web client. -## findOne with _id -The best way to access the document if you have an id is -``` -// Non-reactive -// An example of accessing a document by its id -final id = 'DGbsysgxzSf7Cr8Jg'; -final doc = meteor.collectionCurrentValue('your_collection_name')[id]; +### Looking up a document by id + +Since the collection is a map keyed by `_id`, a lookup is just an index +operation: + +```dart +// Non-reactive read of a document by its id. +final doc = meteor.collectionCurrentValue('your_collection_name')?['DGbsysgxzSf7Cr8Jg']; if (doc != null) { // do something } -// Non-reactive -// An example of accessing a user by userId -final userId = 'Sf7Cr8JgDGbsysgxz'; -final user = meteor.collectionCurrentValue('users')[userId]; -if (user != null) { - // do something -} +// The same works for users. +final user = meteor.collectionCurrentValue('users')?['Sf7Cr8JgDGbsysgxz']; +``` + +### Reading current values without a stream + +When you only need the latest value for a condition check — not a reactive +rebuild — every major stream has a non-reactive counterpart: + +| Reactive stream | Current value | +| --- | --- | +| `meteor.collection(name)` | `meteor.collectionCurrentValue(name)` | +| `meteor.user()` | `meteor.userCurrentValue()` | +| `meteor.userId()` | `meteor.userIdCurrentValue()` | + +## Accounts + +```dart +// Log in (works with a username or an email address; the password is sent +// as a SHA-256 digest, never in plain text). +final result = await meteor.loginWithPassword('user_or_email', 'password'); + +// Resume a session with a saved token, e.g. after an app restart. +await meteor.loginWithToken(token: result.token, tokenExpires: result.tokenExpires); + +// Log out. +await meteor.logout(); +``` + +Related APIs: `meteor.user()`, `meteor.userId()`, `meteor.loggingIn()`, and +`meteor.logInStatus()` are reactive streams of the current account state; +`logoutOtherClients()`, `changePassword()`, `forgotPassword()`, and +`resetPassword()` cover the rest of the standard accounts flows. After a +reconnect the client re-authenticates automatically using its stored token. + +## Connection management + +```dart +meteor.status(); // Stream: connected/connecting/failed/waiting/offline +meteor.reconnect(); // force a reconnection attempt if not connected +meteor.disconnect(); // close the connection and stop reconnecting ``` +While connected, the client exchanges DDP ping/pong with the server and +reconnects (with backoff) when the connection is considered dead. + +## Error handling + +Server-side `Meteor.Error`s are thrown as `MeteorError`, which exposes +`error`, `reason`, `message`, `details`, `errorType`, and `isClientSafe` — the +same fields you get in a Meteor web client. + +## Upgrading + +See [CHANGELOG.md](CHANGELOG.md) for the full history. The notable breaking +changes: + +- **4.0.0** — requires Dart 3.6+; verified against Meteor 3.x (incl. 3.5.1); + web support via `web_socket_channel`. The `DdpClient.PING_SEC_INTERVAL` and + `DdpClient.PONG_WITHIN_SEC` fields were renamed to the static constants + `DdpClient.pingIntervalSeconds` and `DdpClient.pongTimeoutSeconds`. +- **3.0.0** — `meteor.collection()` streams start with `snapshot.hasData == + true` and an empty map instead of no data. +- **2.0.0** — method/subscription arguments became a named parameter: + `meteor.call('method', args: [...])`, `meteor.subscribe('pub', args: [...])` + (both optional). `prepareCollection()` is no longer needed — just call + `meteor.collection()`. `DateTime` values are supported directly. + ## Features and bugs Please file feature requests and bugs at the [issue tracker][tracker]. @@ -282,3 +282,4 @@ Please file feature requests and bugs at the [issue tracker][tracker]. [tracker]: https://github.com/tanutapi/dart_meteor/issues [rxdart]: https://pub.dev/packages/rxdart [example]: https://github.com/tanutapi/dart_meteor/tree/master/example +[medium]: https://medium.com/@tanutapi/writing-flutter-mobile-application-with-meteor-backend-643d2c1947d0?source=friends_link&sk=52ce2fa2603934e7395e2d19dd54e06c From cda9f955b81d940c747334ea6e5c55a9af90565c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:46:59 +0000 Subject: [PATCH 3/5] Declare supported platforms in pubspec Explicitly list android, ios, linux, macos, web, and windows so pub.dev shows the full platform support instead of inferring it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N1hm1DRToNVwU4D6wtJAt3 --- pubspec.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index f1a3c54..0cf8eb5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,6 +6,14 @@ homepage: https://github.com/tanutapi/dart_meteor environment: sdk: '>=3.6.0 <4.0.0' +platforms: + android: + ios: + linux: + macos: + web: + windows: + dependencies: crypto: ^3.0.6 rxdart: ^0.28.0 From 8fc6779b567545dc310af29af7dd87e0a799d3a7 Mon Sep 17 00:00:00 2001 From: Tanut Apiwong Date: Sun, 16 Aug 2026 00:04:22 +0700 Subject: [PATCH 4/5] Modernize the example app and rewrite the README Rebuild the example as a Simple Meteor Chat client that runs on iOS, Android and Web against the live demo server, replacing the old scaffold. Restructure the Android and iOS projects (new application id dev.tanutapi.dart_meteor_example, Kotlin/Swift entry points, SceneDelegate) and drop generated files that do not belong in version control. Rewrite the package README around the current API: quick start, method calls, subscriptions and collections, accounts, connection management and error handling. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 102 +++- example/.fvmrc | 3 + example/.gitignore | 8 +- example/.idea/libraries/Dart_SDK.xml | 19 - .../.idea/libraries/Flutter_for_Android.xml | 9 - example/.idea/libraries/KotlinJavaRuntime.xml | 15 - example/.idea/modules.xml | 9 - example/.idea/runConfigurations/main_dart.xml | 6 - example/.idea/workspace.xml | 36 -- example/.metadata | 17 +- example/README.md | 85 ++- example/analysis_options.yaml | 10 + example/android/app/build.gradle.kts | 23 +- .../android/app/src/main/AndroidManifest.xml | 4 +- .../dart_meteor_example}/MainActivity.kt | 2 +- example/android/build.gradle.kts | 5 +- example/android/example_android.iml | 29 - example/android/gradle.properties | 5 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/local.properties | 2 +- example/android/settings.gradle.kts | 19 +- example/example.iml | 18 - example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Flutter/Generated.xcconfig | 5 +- .../ios/Flutter/flutter_export_environment.sh | 5 +- example/ios/Runner.xcodeproj/project.pbxproj | 52 +- .../xcshareddata/xcschemes/Runner.xcscheme | 18 + example/ios/Runner/AppDelegate.swift | 7 +- example/ios/Runner/Info.plist | 33 +- example/ios/Runner/SceneDelegate.swift | 6 + example/lib/assets_card.dart | 135 +++++ example/lib/chat_page.dart | 530 ++++++++++++++++++ example/lib/login_page.dart | 155 +++++ example/lib/models.dart | 68 +++ example/pubspec.yaml | 69 +-- example/web/favicon.png | Bin 0 -> 917 bytes example/web/icons/Icon-192.png | Bin 0 -> 5292 bytes example/web/icons/Icon-512.png | Bin 0 -> 8252 bytes example/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes example/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes example/web/index.html | 46 ++ example/web/manifest.json | 35 ++ 42 files changed, 1304 insertions(+), 290 deletions(-) create mode 100644 example/.fvmrc delete mode 100644 example/.idea/libraries/Dart_SDK.xml delete mode 100644 example/.idea/libraries/Flutter_for_Android.xml delete mode 100644 example/.idea/libraries/KotlinJavaRuntime.xml delete mode 100644 example/.idea/modules.xml delete mode 100644 example/.idea/runConfigurations/main_dart.xml delete mode 100644 example/.idea/workspace.xml rename example/android/app/src/main/kotlin/{com/example/dart_meteor_example_app => dev/tanutapi/dart_meteor_example}/MainActivity.kt (67%) delete mode 100644 example/android/example_android.iml delete mode 100644 example/example.iml create mode 100644 example/ios/Runner/SceneDelegate.swift create mode 100644 example/lib/assets_card.dart create mode 100644 example/lib/chat_page.dart create mode 100644 example/lib/login_page.dart create mode 100644 example/lib/models.dart create mode 100644 example/web/favicon.png create mode 100644 example/web/icons/Icon-192.png create mode 100644 example/web/icons/Icon-512.png create mode 100644 example/web/icons/Icon-maskable-192.png create mode 100644 example/web/icons/Icon-maskable-512.png create mode 100644 example/web/index.html create mode 100644 example/web/manifest.json diff --git a/README.md b/README.md index 868f187..b92df4e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Designed to work seamlessly with `StreamBuilder` and `FutureBuilder`. - Method calls with `Future`-based results - Subscriptions and reactive collections as `Stream`s - Accounts: login with password/token, logout, password management -- Automatic reconnection with re-login and re-subscription +- Automatic reconnection with backoff, re-login and re-subscription +- App lifecycle aware: detects a connection that died while the device slept - `DateTime` values are converted to/from EJSON `$date` automatically ## Installation @@ -23,7 +24,7 @@ Add the package to your `pubspec.yaml`: ```yaml dependencies: - dart_meteor: ^4.0.0 + dart_meteor: ^4.1.0 ``` ## Quick start @@ -90,9 +91,11 @@ class MyApp extends StatelessWidget { } ``` -A complete runnable app is in [/example][example], and there is a longer -walk-through covering connection status, authentication, and subscriptions in -[this Medium post][medium]. +A complete runnable app is in [/example][example]: a Flutter chat client +(iOS, Android and Web) that connects to the live demo server at +`https://simple-meteor-chat.tanutapi.dev` and exercises login, subscriptions, +collections and method calls. There is also a longer walk-through covering +connection status, authentication, and subscriptions in [this Medium post][medium]. ## Method calls @@ -251,7 +254,70 @@ meteor.disconnect(); // close the connection and stop reconnecting ``` While connected, the client exchanges DDP ping/pong with the server and -reconnects (with backoff) when the connection is considered dead. +reconnects when the connection is considered dead, backing off between +attempts (0s, 5s, 10s, … up to `maxRetryInterval`) so an unreachable server +does not keep the radio busy. `disconnect()` is final: the client stays offline +until you call `reconnect()`. + +The timings are configurable if the defaults do not suit your server: + +```dart +final meteor = MeteorClient.connect( + url: 'https://yourdomain.com', + pingInterval: const Duration(seconds: 20), + pongTimeout: const Duration(seconds: 5), + maxRetryInterval: const Duration(seconds: 30), + stalenessThreshold: const Duration(seconds: 25), +); +``` + +### App lifecycle (mobile) + +When a phone sleeps, the OS suspends the process: Dart timers stop firing, and +the server can drop the session without the socket ever reporting an error. The +app then wakes up believing it is still connected, and stays that way until the +next ping happens to time out. + +`dart_meteor` is a pure Dart package, so it does not watch Flutter's lifecycle +itself. Forward it from a `WidgetsBindingObserver` — this is the whole +integration: + +```dart +class _MyAppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + meteor.notifyAppResumed(); + } else { + meteor.notifyAppPaused(); + } + } +} +``` + +On resume the client measures by wall clock how long it was actually away +rather than trusting its timers. If the connection has been silent longer than +`stalenessThreshold` it is torn down and replaced immediately, re-resuming the +login and re-subscribing. While paused, the client will not tear down a +connection just because a timer fired late. + +`meteor.checkLiveness()` runs the same check on demand — useful if your app +learns from somewhere else (a connectivity plugin, say) that the network may +have changed. + +The [example app][example] wires this up in `lib/main.dart`. ## Error handling @@ -259,11 +325,35 @@ Server-side `Meteor.Error`s are thrown as `MeteorError`, which exposes `error`, `reason`, `message`, `details`, `errorType`, and `isClientSafe` — the same fields you get in a Meteor web client. +A call that was still in flight when the connection dropped — because the +device slept, or the network went away — throws `MeteorConnectionError` +instead. The two are worth distinguishing: `MeteorError` means the server +considered the request and said no, while `MeteorConnectionError` means you +never heard back and the method may or may not have run. + +```dart +try { + await meteor.call('sendMessage', args: ['hello']); +} on MeteorError catch (err) { + // The server rejected it. +} on MeteorConnectionError catch (err) { + // Never got a reply — offer a retry. +} +``` + +Calls are not resent automatically after a reconnect: a method like +`sendMessage` is not safe to run twice, so whether to retry is left to you. + ## Upgrading See [CHANGELOG.md](CHANGELOG.md) for the full history. The notable breaking changes: +- **4.1.0** — two behaviour changes worth knowing about, both fixes. A method + call that is in flight when the connection drops now throws + `MeteorConnectionError` instead of hanging forever, so `await meteor.call(…)` + can now throw where it previously never returned. And reconnect attempts now + back off instead of retrying immediately. - **4.0.0** — requires Dart 3.6+; verified against Meteor 3.x (incl. 3.5.1); web support via `web_socket_channel`. The `DdpClient.PING_SEC_INTERVAL` and `DdpClient.PONG_WITHIN_SEC` fields were renamed to the static constants diff --git a/example/.fvmrc b/example/.fvmrc new file mode 100644 index 0000000..c300356 --- /dev/null +++ b/example/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "stable" +} \ No newline at end of file diff --git a/example/.gitignore b/example/.gitignore index 79c113f..00d30e5 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -27,11 +27,11 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ -.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ /build/ +/coverage/ # Symbolication related app.*.symbols @@ -43,3 +43,9 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Widget Preview related +.widget_preview/ + +# FVM Version Cache +.fvm/ diff --git a/example/.idea/libraries/Dart_SDK.xml b/example/.idea/libraries/Dart_SDK.xml deleted file mode 100644 index 020e45c..0000000 --- a/example/.idea/libraries/Dart_SDK.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/example/.idea/libraries/Flutter_for_Android.xml b/example/.idea/libraries/Flutter_for_Android.xml deleted file mode 100644 index 24a0bd7..0000000 --- a/example/.idea/libraries/Flutter_for_Android.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/example/.idea/libraries/KotlinJavaRuntime.xml b/example/.idea/libraries/KotlinJavaRuntime.xml deleted file mode 100644 index 2b96ac4..0000000 --- a/example/.idea/libraries/KotlinJavaRuntime.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/example/.idea/modules.xml b/example/.idea/modules.xml deleted file mode 100644 index f778b7a..0000000 --- a/example/.idea/modules.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/example/.idea/runConfigurations/main_dart.xml b/example/.idea/runConfigurations/main_dart.xml deleted file mode 100644 index aab7b5c..0000000 --- a/example/.idea/runConfigurations/main_dart.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/example/.idea/workspace.xml b/example/.idea/workspace.xml deleted file mode 100644 index 5b3388c..0000000 --- a/example/.idea/workspace.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/example/.metadata b/example/.metadata index 4f14196..d34a339 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "be698c48a6750c8cb8e61c740ca9991bb947aba2" + revision: "4cf24164269a5ebf0c16a028a00727d0e77bbb05" channel: "stable" project_type: app @@ -13,14 +13,17 @@ project_type: app migration: platforms: - platform: root - create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 - base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - platform: android - create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 - base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - platform: ios - create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 - base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: web + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 # User provided section diff --git a/example/README.md b/example/README.md index 4a9bda3..87b04ff 100644 --- a/example/README.md +++ b/example/README.md @@ -1,29 +1,74 @@ -# dart_meteor_example_app +# Simple Meteor Chat — dart_meteor example -An example flutter project that use dart_meteor package. +A small Flutter chat client (iOS, Android and Web) built with +[`dart_meteor`](https://pub.dev/packages/dart_meteor). It is a Flutter port of +the Blaze web app [simple-meteor-chat](https://github.com/tanutapi/simple-meteor-chat) +and talks to the public demo server: -## Getting Started +``` +https://simple-meteor-chat.tanutapi.dev +``` -Make a change in lib/main.dart to point to your meteor backend. You can use both http://, https://, ws:// or wsss://. -```dart -import 'package:flutter/material.dart'; -import 'package:dart_meteor/dart_meteor.dart'; +`MeteorClient.connect` turns that into `wss://simple-meteor-chat.tanutapi.dev/websocket` +for you, so the plain site URL is all the app needs (see `lib/main.dart`). -MeteorClient meteor = MeteorClient.connect(url: 'https://yourdomain.com'); -void main() => runApp(MyApp()); -``` +## Sign-in -Run flutter create in this example folder: -``` -flutter create . -``` +There is no sign-up. Use one of the seeded accounts: -Run pub get in this example folder: -``` +| Username | Password | +|----------|-------------| +| `user1` | `password1` | +| `user2` | `password2` | + +Open the app twice (e.g. two browser tabs, or a phone and Chrome) and sign in +as `user1` and `user2` to chat with yourself. + +The demo server is intentionally noisy: it posts a system broadcast every +10 seconds and **wipes the whole chat every minute** — the ⏳ countdown in the +header shows when the next purge happens. + +## Run it + +Flutter ≥ 3.27 (Dart ≥ 3.6). The folder is pinned to the `stable` channel via +[fvm](https://fvm.app) (`.fvmrc`) — if you use fvm, `fvm install` picks it up +and you can prefix the commands below with `fvm`; otherwise plain `flutter` +works too. + +```sh +cd example flutter pub get +flutter run -d chrome # Web +flutter run -d ios # iOS simulator / device +flutter run -d android # Android emulator / device ``` -Open Android emulator or iOS simulator then run the flutter: -``` -flutter run -``` +## What it demonstrates + +| Feature in the app | `dart_meteor` API | +|---------------------------------------------|-------------------| +| One app-wide client, auto-reconnect | `MeteorClient.connect(url: …)` | +| "Connecting…" banner + Retry | `meteor.status()`, `meteor.reconnect()` | +| Login page ↔ chat page switching | `meteor.userId()` / `meteor.userIdCurrentValue()` | +| Sign in / sign out | `meteor.loginWithPassword(user, pass)`, `meteor.logout()` | +| Login errors ("Incorrect password", …) | `MeteorError.reason` | +| "Signed in as Apple Seed" | `meteor.user()` | +| Live message list, purge countdown | `meteor.subscribe('messages')`, `subscribe('status')`, `meteor.collection('messages')`, `collection('status')` | +| Sender names / avatars | `meteor.users` (auto-published `users` collection) | +| Send a message, clear the chat | `meteor.call('sendMessage', args: [text])`, `call('clearAllMessages')` | +| Assets card: pick a user, re-subscribe | `meteor.subscribe('assets', args: [username])`, `SubscriptionHandler.stop()` | + +Files: + +- `lib/main.dart` — creates the `MeteorClient`, `RootPage` (login vs chat), connection banner +- `lib/login_page.dart` — username/password sign-in +- `lib/chat_page.dart` — header, message list, composer +- `lib/assets_card.dart` — subscription with arguments +- `lib/models.dart` — tiny helpers for the raw `Map` documents + +## Point it at your own server + +Run [simple-meteor-chat](https://github.com/tanutapi/simple-meteor-chat) locally +(`meteor run`, or `docker run -p 3000:3000 tanutapi/simple-meteor-chat`) and +change `serverUrl` in `lib/main.dart` to `http://localhost:3000` (Android +emulator: `http://10.0.2.2:3000`). diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 0d29021..cedcc10 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -9,6 +9,16 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts index 3efee3d..881ac5a 100644 --- a/example/android/app/build.gradle.kts +++ b/example/android/app/build.gradle.kts @@ -1,31 +1,30 @@ plugins { id("com.android.application") - id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { - namespace = "com.example.dart_meteor_example_app" + namespace = "dev.tanutapi.dart_meteor_example" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.dart_meteor_example_app" + applicationId = "dev.tanutapi.dart_meteor_example" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. versionCode = flutter.versionCode versionName = flutter.versionName } @@ -39,6 +38,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + flutter { source = "../.." } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 78bbdd2..1376bef 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + - - - - - - - - - - - - - - - - - - - - diff --git a/example/android/gradle.properties b/example/android/gradle.properties index f018a61..e96108c 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index ac3b479..a20f2c4 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/example/android/local.properties b/example/android/local.properties index 4d5d140..6247b48 100644 --- a/example/android/local.properties +++ b/example/android/local.properties @@ -1,5 +1,5 @@ sdk.dir=/Users/tanutapiwong/Library/Android/sdk -flutter.sdk=/Users/tanutapiwong/development/flutter +flutter.sdk=/Users/tanutapiwong/fvm/versions/stable flutter.buildMode=debug flutter.versionName=1.0.0 flutter.versionCode=1 \ No newline at end of file diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts index ab39a10..b28021a 100644 --- a/example/android/settings.gradle.kts +++ b/example/android/settings.gradle.kts @@ -1,11 +1,12 @@ pluginManagement { - val flutterSdkPath = run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") @@ -18,8 +19,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false } include(":app") diff --git a/example/example.iml b/example/example.iml deleted file mode 100644 index e5c8371..0000000 --- a/example/example.iml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/example/ios/Flutter/Generated.xcconfig b/example/ios/Flutter/Generated.xcconfig index 73135ca..e3a38e0 100644 --- a/example/ios/Flutter/Generated.xcconfig +++ b/example/ios/Flutter/Generated.xcconfig @@ -1,6 +1,7 @@ // This is a generated file; do not edit or check into version control. -FLUTTER_ROOT=/Users/tanutapiwong/development/flutter +FLUTTER_ROOT=/Users/tanutapiwong/fvm/versions/stable FLUTTER_APPLICATION_PATH=/Users/tanutapiwong/Projects/dart_meteor/example +FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=/Users/tanutapiwong/Projects/dart_meteor/example/ios/Flutter/ephemeral/Packages/.packages/FlutterFramework COCOAPODS_PARALLEL_CODE_SIGN=true FLUTTER_TARGET=/Users/tanutapiwong/Projects/dart_meteor/example/lib/main.dart FLUTTER_BUILD_DIR=build @@ -8,7 +9,7 @@ FLUTTER_BUILD_NAME=1.0.0 FLUTTER_BUILD_NUMBER=1 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 -DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuMzIuMA==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049YmU2OThjNDhhNg==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049MTg4MTgwMDk0OQ==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My44LjA= +DART_DEFINES=RkxVVFRFUl9CVUlMRF9OQU1FPTEuMC4w,RkxVVFRFUl9CVUlMRF9OVU1CRVI9MQ==,RkxVVFRFUl9WRVJTSU9OPTMuNDcuMA==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049NGNmMjQxNjQyNg==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049NWY3NzYyNTY3Mw==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMy4w DART_OBFUSCATION=false TRACK_WIDGET_CREATION=true TREE_SHAKE_ICONS=false diff --git a/example/ios/Flutter/flutter_export_environment.sh b/example/ios/Flutter/flutter_export_environment.sh index 9295db9..83438a0 100755 --- a/example/ios/Flutter/flutter_export_environment.sh +++ b/example/ios/Flutter/flutter_export_environment.sh @@ -1,13 +1,14 @@ #!/bin/sh # This is a generated file; do not edit or check into version control. -export "FLUTTER_ROOT=/Users/tanutapiwong/development/flutter" +export "FLUTTER_ROOT=/Users/tanutapiwong/fvm/versions/stable" export "FLUTTER_APPLICATION_PATH=/Users/tanutapiwong/Projects/dart_meteor/example" +export "FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=/Users/tanutapiwong/Projects/dart_meteor/example/ios/Flutter/ephemeral/Packages/.packages/FlutterFramework" export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_TARGET=/Users/tanutapiwong/Projects/dart_meteor/example/lib/main.dart" export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_NAME=1.0.0" export "FLUTTER_BUILD_NUMBER=1" -export "DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuMzIuMA==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049YmU2OThjNDhhNg==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049MTg4MTgwMDk0OQ==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My44LjA=" +export "DART_DEFINES=RkxVVFRFUl9CVUlMRF9OQU1FPTEuMC4w,RkxVVFRFUl9CVUlMRF9OVU1CRVI9MQ==,RkxVVFRFUl9WRVJTSU9OPTMuNDcuMA==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049NGNmMjQxNjQyNg==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049NWY3NzYyNTY3Mw==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMy4w" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false" diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 0cfc88f..5ac6e76 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 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 */; }; @@ -47,6 +49,8 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 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 = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -62,6 +66,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,6 +84,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -116,6 +122,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -157,6 +164,9 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -190,6 +200,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -270,6 +283,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -346,9 +360,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -362,14 +377,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 97M85WK68C; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.dartMeteorExampleApp; + PRODUCT_BUNDLE_IDENTIFIER = dev.tanutapi.dartMeteorExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -385,7 +399,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.dartMeteorExampleApp.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.tanutapi.dartMeteorExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -402,7 +416,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.dartMeteorExampleApp.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.tanutapi.dartMeteorExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -417,7 +431,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.dartMeteorExampleApp.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.tanutapi.dartMeteorExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -473,10 +487,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -524,9 +539,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -542,14 +558,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 97M85WK68C; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.dartMeteorExampleApp; + PRODUCT_BUNDLE_IDENTIFIER = dev.tanutapi.dartMeteorExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -565,14 +580,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 97M85WK68C; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.dartMeteorExampleApp; + PRODUCT_BUNDLE_IDENTIFIER = dev.tanutapi.dartMeteorExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -614,6 +628,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index deea000..4e8d10d 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,10 +2,12 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Dart Meteor Example App + Simple Meteor Chat CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - dart_meteor_example_app + dart_meteor_example CFBundlePackageType APPL CFBundleShortVersionString @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/ios/Runner/SceneDelegate.swift b/example/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/example/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/example/lib/assets_card.dart b/example/lib/assets_card.dart new file mode 100644 index 0000000..4f34259 --- /dev/null +++ b/example/lib/assets_card.dart @@ -0,0 +1,135 @@ +import 'package:dart_meteor/dart_meteor.dart'; +import 'package:flutter/material.dart'; + +import 'main.dart'; + +/// A small card that lists the `assets` documents owned by a chosen user. +/// +/// Demonstrates a subscription **with arguments** and re-subscribing when the +/// argument changes: `meteor.subscribe('assets', args: [username])`, stopping +/// the previous handler first. +class AssetsCard extends StatefulWidget { + const AssetsCard({super.key}); + + @override + State createState() => _AssetsCardState(); +} + +class _AssetsCardState extends State { + String? _owner; + SubscriptionHandler? _subscription; + + void _selectOwner(String? owner) { + // Stopping the old subscription makes the server remove its documents + // from the local `assets` collection before the new ones arrive. + _subscription?.stop(); + _subscription = owner == null + ? null + : meteor.subscribe('assets', args: [owner]); + setState(() => _owner = owner); + } + + @override + void dispose() { + _subscription?.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + color: scheme.surfaceContainerLow, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + 'Assets', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + // Every logged-in client automatically receives the + // `users` collection (username + profile) from the + // server's unnamed publication. + StreamBuilder>( + stream: meteor.users, + initialData: meteor.collectionCurrentValue('users'), + builder: (context, snapshot) { + final usernames = (snapshot.data ?? const {}) + .values + .map((u) => u is Map ? u['username'] : null) + .whereType() + .toList() + ..sort(); + final items = >[ + const DropdownMenuItem(value: null, child: Text('—')), + for (final name in usernames) + DropdownMenuItem(value: name, child: Text(name)), + ]; + return DropdownButton( + value: usernames.contains(_owner) ? _owner : null, + items: items, + isDense: true, + underline: const SizedBox.shrink(), + onChanged: _selectOwner, + ); + }, + ), + ], + ), + const SizedBox(height: 4), + StreamBuilder>( + stream: meteor.collection('assets'), + initialData: meteor.collectionCurrentValue('assets'), + builder: (context, snapshot) { + final assets = (snapshot.data ?? const {}) + .values + .whereType>() + .toList(); + if (assets.isEmpty) { + return Text( + 'No property found', + style: TextStyle(fontSize: 12, color: scheme.outline), + ); + } + return Column( + children: [ + for (final asset in assets) + Row( + children: [ + Text(asset['owner']?.toString() ?? '?'), + const Spacer(), + Wrap( + spacing: 4, + children: [ + for (final p in (asset['properties'] as List? ?? + const [])) + Chip( + label: Text('$p'), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ], + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/chat_page.dart b/example/lib/chat_page.dart new file mode 100644 index 0000000..59b4476 --- /dev/null +++ b/example/lib/chat_page.dart @@ -0,0 +1,530 @@ +import 'dart:async'; + +import 'package:dart_meteor/dart_meteor.dart'; +import 'package:flutter/material.dart'; + +import 'assets_card.dart'; +import 'main.dart'; +import 'models.dart'; + +const _gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)], +); + +/// The chat room shown while a user is logged in. +/// +/// Demonstrates: +/// * `meteor.subscribe(...)` in [initState] and `SubscriptionHandler.stop()` +/// in [dispose]; +/// * `meteor.collection('messages')` / `meteor.collection('status')` streams; +/// * `meteor.user()` for the current user document; +/// * `meteor.call(...)` for the `sendMessage` and `clearAllMessages` methods; +/// * `meteor.logout()`. +class ChatPage extends StatefulWidget { + const ChatPage({super.key, required this.userId}); + + final String userId; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + late final SubscriptionHandler _messagesSub; + late final SubscriptionHandler _statusSub; + final _input = TextEditingController(); + final _inputFocus = FocusNode(); + bool _sending = false; + + @override + void initState() { + super.initState(); + // Subscriptions are re-sent automatically by dart_meteor whenever the + // connection is re-established, so subscribing once is enough. + _messagesSub = meteor.subscribe('messages'); + _statusSub = meteor.subscribe('status'); + } + + @override + void dispose() { + _messagesSub.stop(); + _statusSub.stop(); + _input.dispose(); + _inputFocus.dispose(); + super.dispose(); + } + + void _showError(Object error) { + if (!mounted) return; + final text = error is MeteorError + ? (error.reason ?? error.message ?? error.toString()) + : error.toString(); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(text))); + } + + Future _send() async { + final text = _input.text.trim(); + if (text.isEmpty || _sending) return; + _input.clear(); + setState(() => _sending = true); + try { + // The server stamps `from` and `createdAt`; the new document arrives + // through the `messages` subscription like any other change. + await meteor.call('sendMessage', args: [text]); + } catch (e) { + _showError(e); + } finally { + if (mounted) { + setState(() => _sending = false); + _inputFocus.requestFocus(); + } + } + } + + Future _clearAll() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear chat'), + content: const Text('Do you want to delete all chat messages?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await meteor.call('clearAllMessages'); + } catch (e) { + _showError(e); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _Header(onClear: _clearAll), + Expanded(child: _MessageList(currentUserId: widget.userId)), + _Composer( + controller: _input, + focusNode: _inputFocus, + sending: _sending, + onSend: _send, + ), + const AssetsCard(), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.onClear}); + + final Future Function() onClear; + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration(gradient: _gradient), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Row( + children: [ + const Text('💬', style: TextStyle(fontSize: 24)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + 'Simple Meteor Chat', + maxLines: 1, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + // `meteor.user()` emits the current user's document + // (only the fields the server publishes: username, + // profile) and null after logout. + StreamBuilder?>( + stream: meteor.user(), + initialData: meteor.userCurrentValue(), + builder: (context, snapshot) => Text( + 'Signed in as ${displayName(snapshot.data)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const _PurgeCountdown(), + IconButton( + tooltip: 'Clear all messages', + icon: const Icon(Icons.delete_sweep_outlined), + color: Colors.white, + visualDensity: VisualDensity.compact, + onPressed: onClear, + ), + IconButton( + tooltip: 'Logout', + icon: const Icon(Icons.logout), + color: Colors.white, + visualDensity: VisualDensity.compact, + onPressed: meteor.logout, + ), + ], + ), + ), + ), + ); + } +} + +/// "⏳ M:SS" until the server wipes the chat, read from the single +/// `status/chatPurge` document. Turns red for the last 10 seconds. +class _PurgeCountdown extends StatefulWidget { + const _PurgeCountdown(); + + @override + State<_PurgeCountdown> createState() => _PurgeCountdownState(); +} + +class _PurgeCountdownState extends State<_PurgeCountdown> { + late final Timer _ticker; + + @override + void initState() { + super.initState(); + // The document only changes once a minute; tick locally in between. + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _ticker.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: meteor.collection('status'), + initialData: meteor.collectionCurrentValue('status'), + builder: (context, snapshot) { + final doc = snapshot.data?['chatPurge']; + final nextPurgeAt = doc is Map ? doc['nextPurgeAt'] : null; + String label = '-:--'; + bool urgent = false; + if (nextPurgeAt is DateTime) { + final remaining = nextPurgeAt.difference(DateTime.now()); + label = countdown(remaining); + urgent = remaining.inSeconds <= 10; + } + return Tooltip( + message: 'Chat history is cleared automatically every minute', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: urgent ? Colors.red.shade600 : Colors.white24, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '⏳ $label', + style: const TextStyle( + color: Colors.white, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ); + }, + ); + } +} + +class _MessageList extends StatelessWidget { + const _MessageList({required this.currentUserId}); + + final String currentUserId; + + @override + Widget build(BuildContext context) { + // The outer builder listens to `users` so sender names update if a user + // document arrives after (or changes after) its messages. + return StreamBuilder>( + stream: meteor.users, + initialData: meteor.collectionCurrentValue('users'), + builder: (context, usersSnapshot) { + final users = usersSnapshot.data ?? const {}; + return StreamBuilder>( + stream: meteor.collection('messages'), + initialData: meteor.collectionCurrentValue('messages'), + builder: (context, snapshot) { + // The stream emits the whole collection as {_id: document}. It is + // the client's live map, so copy it before sorting. + final messages = (snapshot.data ?? const {}) + .values + .whereType>() + .map(ChatMessage.fromDoc) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + if (messages.isEmpty) { + return const _EmptyState(); + } + // Newest first + reverse:true keeps the view pinned to the bottom + // as new messages arrive, with no manual scrolling. + return ListView.builder( + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 12), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + if (message.isSystem) { + return _SystemBubble(message: message); + } + final sender = users[message.from]; + return _MessageBubble( + message: message, + sender: sender is Map ? sender : null, + isOwn: message.from == currentUserId, + ); + }, + ); + }, + ); + }, + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('🗨️', style: TextStyle(fontSize: 40)), + const SizedBox(height: 8), + Text( + 'No messages yet — say hello!', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ], + ), + ); + } +} + +class _SystemBubble extends StatelessWidget { + const _SystemBubble({required this.message}); + + final ChatMessage message; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Center( + child: Tooltip( + message: hhmm(message.createdAt), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: scheme.outlineVariant), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '📢 ${message.text}', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant), + ), + ), + ), + ); + } +} + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({ + required this.message, + required this.sender, + required this.isOwn, + }); + + final ChatMessage message; + final Map? sender; + final bool isOwn; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final time = Text( + hhmm(message.createdAt), + style: TextStyle( + fontSize: 11, + color: isOwn ? Colors.white70 : scheme.outline, + ), + ); + + final bubble = Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.72, + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + gradient: isOwn ? _gradient : null, + color: isOwn ? null : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isOwn ? 18 : 4), + bottomRight: Radius.circular(isOwn ? 4 : 18), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (!isOwn) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + displayName(sender), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: scheme.primary, + ), + ), + ), + Text( + message.text, + style: TextStyle(color: isOwn ? Colors.white : scheme.onSurface), + ), + const SizedBox(height: 2), + time, + ], + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + mainAxisAlignment: + isOwn ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isOwn) ...[ + Tooltip( + message: sender?['username'] as String? ?? '', + child: Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: const BoxDecoration( + gradient: _gradient, + shape: BoxShape.circle, + ), + child: Text( + initialOf(sender), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 8), + ], + bubble, + ], + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer({ + required this.controller, + required this.focusNode, + required this.sending, + required this.onSend, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final bool sending; + final VoidCallback onSend; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surface, + elevation: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + autofocus: true, + textInputAction: TextInputAction.send, + onSubmitted: (_) => onSend(), + decoration: InputDecoration( + hintText: 'Type a message…', + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: sending ? null : onSend, + icon: const Icon(Icons.send, size: 18), + label: const Text('Send'), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/login_page.dart b/example/lib/login_page.dart new file mode 100644 index 0000000..e39b305 --- /dev/null +++ b/example/lib/login_page.dart @@ -0,0 +1,155 @@ +import 'package:dart_meteor/dart_meteor.dart'; +import 'package:flutter/material.dart'; + +import 'main.dart'; + +/// Username / password sign-in against the Meteor `accounts-password` package. +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _username = TextEditingController(); + final _password = TextEditingController(); + bool _busy = false; + String? _error; + + @override + void dispose() { + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _signIn() async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + // On success `meteor.userId()` emits the new id and RootPage swaps to + // the chat screen, so nothing else is needed here. + await meteor.loginWithPassword(_username.text.trim(), _password.text); + } on MeteorError catch (e) { + // Meteor sends e.g. {error: 403, reason: 'Incorrect password'}. + setState(() => _error = e.reason ?? e.message ?? 'Sign in failed'); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)], + ), + ), + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(28), + child: AutofillGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('💬', style: TextStyle(fontSize: 40)), + const SizedBox(height: 8), + Text( + 'Simple Meteor Chat', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + 'Sign in to join the conversation', + textAlign: TextAlign.center, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + TextField( + controller: _username, + autofillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: true, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + onSubmitted: (_) => _signIn(), + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _error!, + style: TextStyle(color: scheme.onErrorContainer), + ), + ), + ], + const SizedBox(height: 20), + FilledButton( + onPressed: _busy ? null : _signIn, + child: _busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign in'), + ), + const SizedBox(height: 16), + Text( + 'Try user1 / password1 or user2 / password2', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/models.dart b/example/lib/models.dart new file mode 100644 index 0000000..955384f --- /dev/null +++ b/example/lib/models.dart @@ -0,0 +1,68 @@ +/// Small helpers that turn the raw `Map` documents delivered +/// by `meteor.collection(...)` into something convenient for the UI. +/// +/// dart_meteor already converts EJSON `{$date: ...}` values into [DateTime] +/// and adds the `_id` field to every document, so no extra decoding is needed. +library; + +class ChatMessage { + const ChatMessage({ + required this.id, + required this.from, + required this.text, + required this.createdAt, + required this.isSystem, + }); + + factory ChatMessage.fromDoc(Map doc) { + return ChatMessage( + id: doc['_id'] as String, + from: doc['from'] as String?, + text: doc['msg'] as String? ?? '', + createdAt: doc['createdAt'] as DateTime? ?? + DateTime.fromMillisecondsSinceEpoch(0), + isSystem: doc['system'] == true, + ); + } + + final String id; + + /// The `_id` of the sender in the `users` collection, or null for + /// server broadcasts. + final String? from; + final String text; + final DateTime createdAt; + final bool isSystem; +} + +/// "Name Surname" from a `users` document, falling back to the username. +String displayName(Map? user) { + if (user == null) return 'Unknown'; + final profile = user['profile']; + if (profile is Map) { + final name = '${profile['name'] ?? ''} ${profile['surname'] ?? ''}'.trim(); + if (name.isNotEmpty) return name; + } + return user['username'] as String? ?? 'Unknown'; +} + +/// First letter of the username, upper-cased, used for avatars. +String initialOf(Map? user) { + final username = user?['username'] as String?; + if (username == null || username.isEmpty) return '?'; + return username.substring(0, 1).toUpperCase(); +} + +String _two(int n) => n.toString().padLeft(2, '0'); + +/// Local time as `HH:mm`. +String hhmm(DateTime time) { + final local = time.toLocal(); + return '${_two(local.hour)}:${_two(local.minute)}'; +} + +/// Remaining time as `M:SS`, clamped at 0:00. +String countdown(Duration remaining) { + final seconds = remaining.isNegative ? 0 : remaining.inSeconds; + return '${seconds ~/ 60}:${_two(seconds % 60)}'; +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 665af23..efd11e4 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,29 +1,14 @@ -name: dart_meteor_example_app -description: A new Flutter project. -publish_to: none - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +name: dart_meteor_example +description: "Simple Meteor Chat — a Flutter (iOS / Android / Web) chat client built with dart_meteor." +publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=3.8.0 <4.0.0" - flutter: ">=3.32.0" + sdk: ^3.6.0 dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 dart_meteor: path: ../ @@ -31,53 +16,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/example/web/icons/Icon-maskable-192.png b/example/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/web/icons/Icon-maskable-512.png b/example/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/web/index.html b/example/web/index.html new file mode 100644 index 0000000..1d84b0e --- /dev/null +++ b/example/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + Simple Meteor Chat + + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..a582cb5 --- /dev/null +++ b/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Simple Meteor Chat", + "short_name": "Meteor Chat", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Simple Meteor Chat — a dart_meteor example app.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} From c26b978c5bf1519e20b5fbf9e7d04771d1f76b12 Mon Sep 17 00:00:00 2001 From: Tanut Apiwong Date: Sun, 16 Aug 2026 00:04:42 +0700 Subject: [PATCH 5/5] Fix connection lifecycle for sleeping devices (4.1.0) A device that sleeps suspends the process: timers stop firing and the server drops the session without the socket ever reporting an error. The client handled none of that well. - A missed pong left the client permanently offline. The pong timeout called disconnect(), which also disabled reconnection, so no retry was ever scheduled - the exact path a device takes when it wakes with a stale socket. Split the user-initiated disconnect() from an internal connection-lost path that always schedules a retry. - Reconnect backoff never engaged: retryCount was reset on every attempt, so the interval was always 0s and maxRetryCount was never reached. Reset it only once the server accepts the connection. - Connecting to an unreachable server raised an unhandled exception that could terminate the application, because WebSocketChannel.connect fails asynchronously rather than throwing. Await ready and route the failure into the normal retry path. - In-flight method calls never completed when the connection dropped. They now complete with the new MeteorConnectionError. Calls are not resent, since methods are not necessarily idempotent. - Subscriptions were re-sent before the login token was resumed, so publications depending on this.userId re-ran unauthenticated after every reconnect. Re-subscription now awaits the onReconnect callbacks. - disconnect() did not cancel a pending reconnect timer, so an explicit disconnect could be undone by an already-scheduled retry. - status() emitted the client's own mutable object, so fast transitions could be misread by subscribers. Each event is now a snapshot. Add notifyAppPaused()/notifyAppResumed()/checkLiveness(). On resume the client compares the wall clock against the last message received and replaces a stale connection immediately instead of waiting for a ping to time out. The package stays pure Dart; the Flutter WidgetsBindingObserver glue is a few lines, shown in the README and wired into the example app. Ping, pong, backoff and staleness durations are now configurable. Add test/lifecycle_test.dart, a server-free regression suite for all of the above, and run it in CI. The mock DDP server moves to test/mock_ddp_server.dart so both suites share it. In the integration suite, await the reactive user document instead of reading it synchronously after login (it arrives ~30ms later, as a separate DDP message) and guard completers that a stream can fire more than once. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/dart.yml | 2 + CHANGELOG.md | 48 +++ example/lib/main.dart | 237 +++++++++------ lib/src/ddp_client.dart | 521 ++++++++++++++++++++++++--------- lib/src/meteor_client.dart | 50 +++- pubspec.yaml | 2 +- test/dart_meteor_test.dart | 40 ++- test/ddp_mock_server_test.dart | 226 +------------- test/lifecycle_test.dart | 248 ++++++++++++++++ test/mock_ddp_server.dart | 280 ++++++++++++++++++ 10 files changed, 1178 insertions(+), 476 deletions(-) create mode 100644 test/lifecycle_test.dart create mode 100644 test/mock_ddp_server.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 38535e6..dca0f4f 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -18,6 +18,8 @@ jobs: run: dart analyze - name: Run DDP protocol tests (no server required) run: dart test test/ddp_mock_server_test.dart + - name: Run connection lifecycle tests (no server required) + run: dart test test/lifecycle_test.dart - name: Prepare docker network run: docker network create test_net - name: Start MongoDB diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f894c6..96da65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,51 @@ +# 4.1.0 + +Connection lifecycle fixes. The theme is a device that goes to sleep: the +process is suspended, timers stop, and the server drops the session without the +socket ever reporting an error. + +Fixed: +- A missed `pong` left the client permanently offline. The pong timeout called + `disconnect()`, which also disabled reconnection, so no retry was ever + scheduled — the exact path a device takes when it wakes with a stale socket. +- Reconnect backoff never engaged. `retryCount` was reset on every connection + attempt, so the computed interval was always 0s and `maxRetryCount` was never + reached, producing a tight reconnect loop. The counter is now reset only once + the server accepts the connection. +- Connecting to an unreachable server raised an **unhandled** exception that + could terminate the application, because `WebSocketChannel.connect` fails + asynchronously rather than throwing. The client now awaits `ready` and routes + the failure into its normal retry path. +- A method call that was in flight when the connection dropped never completed. + Pending calls are now completed with the new `MeteorConnectionError`. +- Subscriptions were re-sent before the login token was resumed, so + publications depending on `this.userId` re-ran unauthenticated after every + reconnect. Re-subscription now waits for the `onReconnect` callbacks. +- `disconnect()` did not cancel a pending reconnect timer, so an explicit + disconnect could be undone by a retry that was already scheduled. +- `status()` emitted the client's own mutable status object, so a fast + transition could be misread by subscribers. Each event is now a snapshot. + +Added: +- `meteor.notifyAppPaused()` / `meteor.notifyAppResumed()` and + `meteor.checkLiveness()`. On resume the client compares the wall clock + against the last message received and replaces a stale connection + immediately, instead of waiting for a ping to time out. The package stays + pure Dart; the Flutter `WidgetsBindingObserver` glue is a few lines shown in + the README and in `example/`. +- Configurable `pingInterval`, `pongTimeout`, `maxRetryInterval` and + `stalenessThreshold` on `MeteorClient.connect` and `DdpClient`. +- `test/lifecycle_test.dart`, a server-free regression suite for the above; the + mock DDP server moved to `test/mock_ddp_server.dart` so both suites share it. + +Behaviour changes to be aware of when upgrading: +- `await meteor.call(...)` can now throw `MeteorConnectionError` where it + previously hung forever. In-flight calls are **not** retried automatically, + since methods are not necessarily idempotent. +- Reconnect attempts are spaced out rather than immediate. +- `onReconnect` callbacks may now return a `Future`, which is awaited before + subscriptions are re-sent. + # 4.0.0 - Support for the latest Dart/Flutter releases (tested with Dart 3.13). The minimum SDK is now Dart 3.6. - Verified compatibility with Meteor 3.x servers, including Meteor 3.5.1 (DDP protocol version 1, SHA-256 password login, EJSON `$date` handling). diff --git a/example/lib/main.dart b/example/lib/main.dart index 35c390a..662dbf9 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,115 +1,158 @@ -import 'package:flutter/material.dart'; import 'package:dart_meteor/dart_meteor.dart'; +import 'package:flutter/material.dart'; + +import 'chat_page.dart'; +import 'login_page.dart'; -MeteorClient meteor = MeteorClient.connect(url: 'https://yourdomain.com'); -void main() => runApp(MyApp()); +/// The demo server. `MeteorClient.connect` turns `https://` into `wss://` and +/// appends `/websocket` automatically, so a plain site URL is enough. +const serverUrl = 'https://simple-meteor-chat.tanutapi.dev'; -class MyApp extends StatefulWidget { - const MyApp({super.key}); +/// A single, app-wide client. It starts connecting as soon as it is created +/// and keeps reconnecting (and re-subscribing / resuming the login token) on +/// its own when the connection drops. +final MeteorClient meteor = MeteorClient.connect(url: serverUrl); + +void main() { + runApp(const SimpleMeteorChatApp()); +} + +class SimpleMeteorChatApp extends StatefulWidget { + const SimpleMeteorChatApp({super.key}); @override - MyAppState createState() => MyAppState(); + State createState() => _SimpleMeteorChatAppState(); } -class MyAppState extends State { - String _methodResult = ''; +/// Forwards the app lifecycle to the Meteor client. +/// +/// This is the whole integration: while the device is asleep the process is +/// suspended, so timers stop firing and the server may drop the connection +/// without the socket ever reporting an error. Telling the client when the app +/// resumes lets it check by wall clock how long it was really away and replace +/// a dead connection immediately, instead of looking connected until the next +/// ping happens to time out. +class _SimpleMeteorChatAppState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } - void _callMethod() { - meteor.call('helloMethod').then((result) { - setState(() { - _methodResult = result.toString(); - }); - }).catchError((err) { - if (err is MeteorError) { - setState(() { - _methodResult = err.message ?? 'Unknown error'; - }); - } - }); + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + meteor.notifyAppResumed(); + } else { + meteor.notifyAppPaused(); + } } @override Widget build(BuildContext context) { return MaterialApp( - home: Scaffold( - appBar: AppBar( - title: Text('Package dart_meteor Example'), - ), - body: Container( - padding: EdgeInsets.all(8.0), - child: Column( - children: [ - StreamBuilder( - stream: meteor.status(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - if (snapshot.data!.status == - DdpConnectionStatusValues.connected) { - return ElevatedButton( - onPressed: () { - meteor.disconnect(); - }, - child: Text('Disconnect'), - ); - } - return ElevatedButton( - onPressed: () { - meteor.reconnect(); - }, - child: Text('Connect'), - ); - } - return Container(); - }, - ), - StreamBuilder( - stream: meteor.status(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return Text('Meteor Status ${snapshot.data!.toString()}'); - } - return Text('Meteor Status: ---'); - }, - ), - StreamBuilder( - stream: meteor.userId(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return ElevatedButton( - onPressed: () { - meteor.logout(); - }, - child: Text('Logout'), - ); - } - return ElevatedButton( - onPressed: () { - debugPrint('Logging in...'); - meteor.loginWithPassword('yourusername', 'yourpassword').then((res) { - debugPrint(res.token); - }); - }, - child: Text('Login'), - ); - }), - StreamBuilder( - stream: meteor.user(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return Text(snapshot.data.toString()); - } - return Text('User: ----'); - }, - ), - ElevatedButton( - onPressed: _callMethod, - child: Text('Method Call'), - ), - Text(_methodResult), - ], + title: 'Simple Meteor Chat', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6366F1)), + useMaterial3: true, + ), + home: const RootPage(), + ); + } +} + +/// Shows [LoginPage] while nobody is logged in and [ChatPage] otherwise. +/// +/// `meteor.userId()` emits `null` after logout and the user id after a +/// successful login (or a token resume), so switching on it is all that is +/// needed for navigation. +class RootPage extends StatelessWidget { + const RootPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + const ConnectionBanner(), + Expanded( + child: StreamBuilder( + stream: meteor.userId(), + initialData: meteor.userIdCurrentValue(), + builder: (context, snapshot) { + final userId = snapshot.data; + if (userId == null) { + return const LoginPage(); + } + // Keying on the user id resets the chat state when a + // different user signs in. + return ChatPage(key: ValueKey(userId), userId: userId); + }, + ), ), - ), + ], ), ); } } + +/// A slim strip at the top of the screen that is only visible while the DDP +/// connection is not established. +class ConnectionBanner extends StatelessWidget { + const ConnectionBanner({super.key}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: meteor.status(), + builder: (context, snapshot) { + final status = snapshot.data; + if (status != null && status.connected) { + return const SizedBox.shrink(); + } + final label = switch (status?.status) { + null || DdpConnectionStatusValues.connecting => 'Connecting…', + DdpConnectionStatusValues.waiting => + 'Reconnecting… (attempt ${status!.retryCount})', + DdpConnectionStatusValues.failed => 'Connection failed', + DdpConnectionStatusValues.offline => 'Offline', + DdpConnectionStatusValues.connected => 'Connected', + }; + final scheme = Theme.of(context).colorScheme; + return Material( + color: scheme.errorContainer, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Icon(Icons.cloud_off, size: 18, color: scheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle(color: scheme.onErrorContainer), + ), + ), + TextButton( + onPressed: meteor.reconnect, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/ddp_client.dart b/lib/src/ddp_client.dart index 37cc9f9..92d8fb6 100644 --- a/lib/src/ddp_client.dart +++ b/lib/src/ddp_client.dart @@ -11,6 +11,18 @@ enum DdpConnectionStatusValues { offline } +/// Thrown into an in-flight method call when the connection is lost before +/// the server replied. Callers can catch this to distinguish "the server said +/// no" (a [MeteorError]) from "we never heard back" - for example after the +/// device slept mid-call. +class MeteorConnectionError extends Error { + final String reason; + MeteorConnectionError(this.reason); + + @override + String toString() => 'MeteorConnectionError: $reason'; +} + class DdpConnectionStatus { bool connected; DdpConnectionStatusValues status; @@ -26,6 +38,19 @@ class DdpConnectionStatus { required this.reason, }); + /// A point-in-time copy. The client keeps one mutable status internally; + /// stream subscribers get a snapshot each, so a transition is still readable + /// by the time the event is delivered even if the client has moved on. + DdpConnectionStatus copy() { + return DdpConnectionStatus( + connected: connected, + status: status, + retryCount: retryCount, + retryTime: retryTime, + reason: reason, + ); + } + @override String toString() { return 'connected: $connected, status: $status, retryCount: $retryCount, retryTime: $retryTime, reason: $reason'; @@ -90,17 +115,38 @@ class DdpClient { String url; String userAgent; WebSocketChannel? _socket; + StreamSubscription? _socketSubscription; int maxRetryCount; + + /// How often a `ping` is sent while the connection is up. + final Duration pingInterval; + + /// How long to wait for the matching `pong` before treating the connection + /// as dead. + final Duration pongTimeout; + + /// Longest gap between reconnect attempts. + final Duration maxRetryInterval; + + /// If no message at all has been received for this long, the socket is + /// considered stale on the next liveness check. A suspended process (device + /// asleep) resumes with a large gap here even though its timers never fired, + /// which is what lets the client notice immediately rather than waiting a + /// full ping cycle. + final Duration stalenessThreshold; + final Map _onReconnectCallbacks = {}; String? serverId; String? sessionId; int _currentMethodId = 0; - bool _flagToBeResetAtPongMsg = false; Timer? _pingPeriodicTimer; + Timer? _pongTimeoutTimer; + DateTime? _lastMessageReceivedAt; final Map> _methodCompleters = {}; final Map _subscriptions = {}; final Map _subscriptionHandlers = {}; bool _isTryToReconnect = true; + bool _appIsPaused = false; Timer? _scheduleReconnectTimer; final bool debug; @@ -110,7 +156,22 @@ class DdpClient { this.maxRetryCount = 20, this.debug = false, required this.userAgent, - }) { + Duration? pingInterval, + Duration? pongTimeout, + Duration? maxRetryInterval, + Duration? stalenessThreshold, + }) : pingInterval = + pingInterval ?? const Duration(seconds: pingIntervalSeconds), + pongTimeout = pongTimeout ?? const Duration(seconds: pongTimeoutSeconds), + maxRetryInterval = maxRetryInterval ?? const Duration(seconds: 30), + stalenessThreshold = stalenessThreshold ?? + Duration( + seconds: (pingInterval ?? + const Duration(seconds: pingIntervalSeconds)) + .inSeconds + + (pongTimeout ?? const Duration(seconds: pongTimeoutSeconds)) + .inSeconds, + ) { _connectionStatus = DdpConnectionStatus( connected: false, status: DdpConnectionStatusValues.waiting, @@ -118,10 +179,17 @@ class DdpClient { retryTime: Duration(seconds: 0), reason: null, ); - _statusStreamController.sink.add(_connectionStatus); + _emitStatus(); _connect(); } + /// Publish a snapshot of the current status. Subscribers must never receive + /// the client's own mutable instance, or a fast transition (offline -> + /// waiting -> connecting) would be unreadable by the time it is delivered. + void _emitStatus() { + _statusStreamController.sink.add(_connectionStatus.copy()); + } + void printDebug(String str) { if (debug) { print('DDP[${_socket.hashCode}] - ${DateTime.now()}'); @@ -132,10 +200,15 @@ class DdpClient { /// Register a function to call as the first step of reconnecting. /// This function can call methods which will be executed before any other outstanding methods. /// For example, this can be used to re-establish the appropriate authentication context on the connection. + /// + /// The callback may return a [Future]; subscriptions are not re-sent until + /// it completes, so a publication that depends on `this.userId` sees the + /// resumed login rather than an anonymous connection. + /// /// callback: /// The function to call. It will be called with a single argument, the connection object that is reconnecting. void onReconnect( - void Function(OnReconnectionCallback reconnection) callback) { + FutureOr Function(OnReconnectionCallback reconnection) callback) { var id = _generateUID(16); var onReconnectCallback = OnReconnectionCallback(ddpClient: this, id: id, callback: callback); @@ -170,9 +243,13 @@ class DdpClient { var methodCompleter = Completer(); var newId = _currentMethodId.toString(); params = DdpClient.escapeSpecialFieldValues(params); - _sendMsgMethod(method, params, newId); _currentMethodId++; _methodCompleters[newId] = methodCompleter; + if (_socket == null) { + _failMethodCall(newId, 'Not connected to the server'); + } else { + _sendMsgMethod(method, params, newId); + } return methodCompleter.future; } @@ -180,114 +257,252 @@ class DdpClient { return _statusStreamController.stream; } + /// Force an immediate reconnection attempt if the client is not connected. void reconnect() { printDebug('Reconnect: the connection status is ... $_connectionStatus'); if (_connectionStatus.status != DdpConnectionStatusValues.connected && _connectionStatus.status != DdpConnectionStatusValues.connecting) { - if (_scheduleReconnectTimer != null) { - if (_scheduleReconnectTimer!.isActive) { - _scheduleReconnectTimer!.cancel(); - _scheduleReconnectTimer = null; - } - } + _cancelScheduledReconnect(); + _connectionStatus.retryCount = 0; _connect(); } } + /// Tell the client the host application went to the background. + /// + /// The connection is left alone - the OS may keep it alive - but the client + /// stops assuming its timers are reliable from this point on. + void notifyAppPaused() { + printDebug('App paused'); + _appIsPaused = true; + } + + /// Tell the client the host application came back to the foreground. + /// + /// This checks how long it has actually been (by wall clock) since the last + /// message arrived. A process that was suspended wakes up with timers that + /// never fired and a socket the server may have already discarded, so a + /// stale connection is torn down and replaced immediately instead of waiting + /// for the next ping to time out. + void notifyAppResumed() { + printDebug('App resumed'); + _appIsPaused = false; + checkLiveness(); + } + + /// Verify the connection is still alive, tearing it down and reconnecting if + /// it is not. Safe to call at any time. + void checkLiveness() { + if (!_isTryToReconnect) { + // The user explicitly disconnected; leave it alone. + return; + } + if (_connectionStatus.status == DdpConnectionStatusValues.connected) { + var last = _lastMessageReceivedAt; + var silentFor = last == null + ? stalenessThreshold + : DateTime.now().difference(last); + if (silentFor >= stalenessThreshold) { + printDebug( + 'Connection considered stale - nothing received for $silentFor', + ); + _handleConnectionLost('Connection went stale while suspended'); + } + return; + } + // Not connected: the user is looking at the app, so try again now rather + // than sitting out the remaining backoff. + _cancelScheduledReconnect(); + _connectionStatus.retryCount = 0; + _connect(); + } + + /// Disconnect the client from the server. The client stays offline until + /// [reconnect] is called. void disconnect() { printDebug('Begin of disconnect()'); _isTryToReconnect = false; - if (_socket != null) { - _socket!.sink.close().then((value) { - _socket = null; - }).catchError((err) { - printDebug(err); - _socket = null; + _cancelScheduledReconnect(); + _teardownConnection('Disconnected by the client'); + _connectionStatus.retryCount = 0; + _connectionStatus.connected = false; + _connectionStatus.status = DdpConnectionStatusValues.offline; + _connectionStatus.reason = null; + _emitStatus(); + printDebug('End of disconnect()'); + } + + /// Close the current socket and release everything attached to it, without + /// deciding whether to reconnect. [reason] is reported to any in-flight + /// method calls. + void _teardownConnection(String reason) { + _pingPeriodicTimer?.cancel(); + _pingPeriodicTimer = null; + _pongTimeoutTimer?.cancel(); + _pongTimeoutTimer = null; + + var subscription = _socketSubscription; + _socketSubscription = null; + subscription?.cancel().catchError((Object err) { + printDebug('Error while cancelling the socket subscription: $err'); + }); + + var socket = _socket; + _socket = null; + if (socket != null) { + socket.sink.close().catchError((Object err) { + printDebug('Error while closing the socket: $err'); }); } - // Cancel ping-pong timer - if (_pingPeriodicTimer != null) { - _pingPeriodicTimer!.cancel(); - _pingPeriodicTimer = null; - } - - // Reset ping-pong flag - _flagToBeResetAtPongMsg = false; serverId = null; sessionId = null; + _lastMessageReceivedAt = null; + _failAllPendingMethodCalls(reason); + } + + /// Handle a connection that dropped on its own (socket closed, error, or a + /// missed pong) as opposed to one the user closed. Always schedules a + /// reconnect. + void _handleConnectionLost(String reason) { + if (_connectionStatus.status == DdpConnectionStatusValues.waiting) { + // A reconnect is already pending; nothing more to do. + return; + } + printDebug('Connection lost: $reason'); + _teardownConnection(reason); _connectionStatus.connected = false; _connectionStatus.status = DdpConnectionStatusValues.offline; - _connectionStatus.retryCount = 0; - _connectionStatus.reason = null; - _statusStreamController.sink.add(_connectionStatus); - printDebug('End of disconnect()'); + _connectionStatus.reason = reason; + _emitStatus(); + if (_isTryToReconnect) { + _scheduleReconnect(); + } + } + + void _failMethodCall(String id, String reason) { + var completer = _methodCompleters.remove(id); + if (completer != null && !completer.isCompleted) { + completer.completeError(MeteorConnectionError(reason)); + } + } + + void _failAllPendingMethodCalls(String reason) { + if (_methodCompleters.isEmpty) { + return; + } + printDebug( + 'Failing ${_methodCompleters.length} in-flight method call(s): $reason', + ); + var pending = Map>.from(_methodCompleters); + _methodCompleters.clear(); + pending.forEach((id, completer) { + if (!completer.isCompleted) { + completer.completeError(MeteorConnectionError(reason)); + } + }); + } + + void _cancelScheduledReconnect() { + _scheduleReconnectTimer?.cancel(); + _scheduleReconnectTimer = null; } void _connect() async { - if (_connectionStatus.status != DdpConnectionStatusValues.connected && - _connectionStatus.status != DdpConnectionStatusValues.connecting) { - _isTryToReconnect = true; - _connectionStatus.status = DdpConnectionStatusValues.connecting; - _connectionStatus.reason = null; - _statusStreamController.sink.add(_connectionStatus); - try { - _socket = WebSocketChannel.connect(Uri.parse(url)); - _connectionStatus.retryCount = 0; - _connectionStatus.retryTime = Duration(seconds: 1); - _socket!.stream.listen( - _onData, - onDone: _onDone, - onError: _onError, - cancelOnError: true, - ); - _sendMsgConnect(); - } catch (err) { - print(err); - _connectionStatus.status = DdpConnectionStatusValues.failed; - _connectionStatus.reason = err.toString(); - _statusStreamController.sink.add(_connectionStatus); - _socket = null; - printDebug( - 'Schedule to reconnect due to websocket exception while trying to connect to the server!', - ); - _scheduleReconnect(); + if (_connectionStatus.status == DdpConnectionStatusValues.connected || + _connectionStatus.status == DdpConnectionStatusValues.connecting) { + return; + } + _isTryToReconnect = true; + _connectionStatus.status = DdpConnectionStatusValues.connecting; + _connectionStatus.reason = null; + _emitStatus(); + + WebSocketChannel channel; + try { + channel = WebSocketChannel.connect(Uri.parse(url)); + } catch (err) { + printDebug('Failed to create the websocket: $err'); + _handleConnectionLost('Failed to create the websocket: $err'); + return; + } + _socket = channel; + + // The sink reports failures on its `done` future. Without a handler these + // escape as unhandled async errors and take the whole application down. + unawaited(channel.sink.done.catchError((Object err) { + printDebug('Websocket sink closed with an error: $err'); + return null; + })); + + try { + await channel.ready; + } catch (err) { + if (!identical(_socket, channel)) { + // Superseded by a newer attempt (or an explicit disconnect). + return; } + printDebug('Websocket failed to connect: $err'); + _handleConnectionLost('Websocket failed to connect: $err'); + return; + } + + if (!identical(_socket, channel)) { + // The client moved on while we were connecting. + channel.sink.close().catchError((Object err) => null); + return; } + + _lastMessageReceivedAt = DateTime.now(); + _socketSubscription = channel.stream.listen( + _onData, + onDone: _onDone, + onError: _onError, + cancelOnError: true, + ); + _sendMsgConnect(); } void _scheduleReconnect() { - if (_connectionStatus.status == DdpConnectionStatusValues.offline || - _connectionStatus.status == DdpConnectionStatusValues.failed) { - _connectionStatus.retryCount++; - if (_connectionStatus.retryCount <= maxRetryCount) { - _connectionStatus.connected = false; - _connectionStatus.status = DdpConnectionStatusValues.waiting; - _connectionStatus.retryTime = - Duration(seconds: min(5 * (_connectionStatus.retryCount - 1), 30)); - _connectionStatus.reason = null; - _statusStreamController.sink.add(_connectionStatus); - printDebug('Retry to connect in ${_connectionStatus.retryTime}'); - - if (_scheduleReconnectTimer != null) { - if (_scheduleReconnectTimer!.isActive) { - _scheduleReconnectTimer!.cancel(); - _scheduleReconnectTimer = null; - } - } - _scheduleReconnectTimer = Timer(_connectionStatus.retryTime, () { - printDebug('Retry to connect count: ${_connectionStatus.retryCount}'); + if (_connectionStatus.status != DdpConnectionStatusValues.offline && + _connectionStatus.status != DdpConnectionStatusValues.failed) { + return; + } + _connectionStatus.retryCount++; + if (_connectionStatus.retryCount <= maxRetryCount) { + _connectionStatus.connected = false; + _connectionStatus.status = DdpConnectionStatusValues.waiting; + _connectionStatus.retryTime = _retryIntervalFor( + _connectionStatus.retryCount, + ); + _emitStatus(); + printDebug('Retry to connect in ${_connectionStatus.retryTime}'); + + _cancelScheduledReconnect(); + _scheduleReconnectTimer = Timer(_connectionStatus.retryTime, () { + _scheduleReconnectTimer = null; + printDebug('Retry to connect count: ${_connectionStatus.retryCount}'); + if (_isTryToReconnect) { _connect(); - }); - } else { - _connectionStatus.connected = false; - _connectionStatus.status = DdpConnectionStatusValues.failed; - _connectionStatus.reason = 'DDP. Reach max retry attempt'; - _statusStreamController.sink.add(_connectionStatus); - } + } + }); + } else { + _connectionStatus.connected = false; + _connectionStatus.status = DdpConnectionStatusValues.failed; + _connectionStatus.reason = 'DDP. Reach max retry attempt'; + _emitStatus(); } } + /// Back off linearly (0s, 5s, 10s, ...) up to [maxRetryInterval] so a device + /// that wakes without a network does not spin on the radio. + Duration _retryIntervalFor(int retryCount) { + var seconds = 5 * (retryCount - 1); + return Duration( + seconds: min(seconds, maxRetryInterval.inSeconds), + ); + } + void _sendMsgConnect() { if (_socket != null) { var data = { @@ -301,32 +516,35 @@ class DdpClient { var msg = json.encode(data); printDebug('Send: $msg'); _socket!.sink.add(msg); - - // Resend all subscriptions - _subscriptionHandlers.forEach((id, handler) { - _sendMsgSub(id, handler.subName, handler.args); - }); } } + /// Re-send every live subscription on a freshly established connection. + /// Called only once the server has replied `connected` and the reconnect + /// callbacks (in practice, the login resume) have finished. + void _resendSubscriptions() { + _subscriptionHandlers.forEach((id, handler) { + _sendMsgSub(id, handler.subName, handler.args); + }); + } + void _sendMsgPing() { if (_socket != null) { var msg = json.encode({'msg': 'ping'}); printDebug('Send: $msg'); _socket!.sink.add(msg); var sentTime = DateTime.now(); - _flagToBeResetAtPongMsg = true; - Future.delayed(Duration(seconds: pongTimeoutSeconds), () { - if (_flagToBeResetAtPongMsg == true) { - printDebug(''); - printDebug('Disconnect due to not receiving PONG'); - printDebug('The latest PING was sent since $sentTime'); - printDebug('The current time is ${DateTime.now()}'); - printDebug( - 'Time diff since the PING was sent is ${DateTime.now().difference(sentTime)}', - ); - disconnect(); - } + // Start the clock on the first unanswered ping only. Restarting it per + // ping would forgive a missed pong forever whenever pongTimeout is not + // shorter than pingInterval; the timer is cleared when a pong arrives. + _pongTimeoutTimer ??= Timer(pongTimeout, () { + _pongTimeoutTimer = null; + printDebug('Disconnect due to not receiving PONG'); + printDebug('The latest PING was sent since $sentTime'); + printDebug( + 'Time diff since the PING was sent is ${DateTime.now().difference(sentTime)}', + ); + _handleConnectionLost('No PONG received within $pongTimeout'); }); } } @@ -383,10 +601,60 @@ class DdpClient { } } + /// Runs once the server accepted the connection: mark the client connected, + /// give the reconnect callbacks a chance to restore the login, then re-send + /// the subscriptions. + Future _onConnected(Map dataMap) async { + _connectionStatus.connected = true; + _connectionStatus.status = DdpConnectionStatusValues.connected; + _connectionStatus.reason = null; + _connectionStatus.retryCount = 0; + _connectionStatus.retryTime = Duration(seconds: 0); + _emitStatus(); + sessionId = dataMap['session']; + + _pingPeriodicTimer?.cancel(); + _pingPeriodicTimer = Timer.periodic(pingInterval, (timer) { + if (_appIsPaused) { + // Timers are unreliable while suspended; liveness is re-checked on + // resume instead of tearing the connection down from a late timer. + return; + } + _sendMsgPing(); + }); + + var callbacks = List.from( + _onReconnectCallbacks.values, + ); + for (var reconnectCallback in callbacks) { + try { + await reconnectCallback.callback(reconnectCallback); + } catch (err) { + printDebug('onReconnect callback failed: $err'); + } + if (_connectionStatus.status != DdpConnectionStatusValues.connected) { + // Lost the connection again while restoring it. + return; + } + } + _resendSubscriptions(); + } + void _onData(dynamic data) { printDebug('Received: $data'); + _lastMessageReceivedAt = DateTime.now(); var dataMap = json.decode(data) ?? {}; var msg = dataMap['msg']; + if (msg == 'ping') { + // Answer regardless of state so the server never times us out. + _sendMsgPong(); + return; + } + if (msg == 'pong') { + _pongTimeoutTimer?.cancel(); + _pongTimeoutTimer = null; + return; + } if (_connectionStatus.status == DdpConnectionStatusValues.connecting) { if (dataMap['server_id'] != null) { serverId = dataMap['server_id']; @@ -394,26 +662,7 @@ class DdpClient { print('DDP[${_socket.hashCode}] - Server ID: $serverId'); } } else if (msg == 'connected') { - for (var reconnectCallback in _onReconnectCallbacks.values) { - reconnectCallback.callback(reconnectCallback); - } - - _connectionStatus.connected = true; - _connectionStatus.status = DdpConnectionStatusValues.connected; - _connectionStatus.reason = null; - _statusStreamController.sink.add(_connectionStatus); - sessionId = dataMap['session']; - - // Cancel ping-pong timer - if (_pingPeriodicTimer != null) { - _pingPeriodicTimer!.cancel(); - _pingPeriodicTimer = null; - } - - _pingPeriodicTimer = - Timer.periodic(Duration(seconds: pingIntervalSeconds), (timer) { - _sendMsgPing(); - }); + unawaited(_onConnected(dataMap)); } else if (msg == 'failed') { serverId = null; sessionId = null; @@ -421,15 +670,11 @@ class DdpClient { _connectionStatus.status = DdpConnectionStatusValues.failed; _connectionStatus.reason = 'Failed connect to server. Protocol version ${dataMap['version']} is suggested!'; - _statusStreamController.sink.add(_connectionStatus); + _emitStatus(); } } else if (_connectionStatus.status == DdpConnectionStatusValues.connected) { - if (msg == 'ping') { - _sendMsgPong(); - } else if (msg == 'pong') { - _flagToBeResetAtPongMsg = false; - } else if (msg == 'nosub') { + if (msg == 'nosub') { if (dataMap['id'] != null) { String id = dataMap['id']; var sub = _subscriptions[id]; @@ -472,7 +717,7 @@ class DdpClient { } else if (msg == 'result') { if (dataMap['id'] != null) { String id = dataMap['id']; - var completer = _methodCompleters[id]; + var completer = _methodCompleters.remove(id); if (completer != null) { if (dataMap['error'] != null) { completer.completeError(dataMap['error']); @@ -481,7 +726,6 @@ class DdpClient { var result = dataMap['result']; completer.complete(result); } - _methodCompleters.remove(id); } else { printDebug('No method completer found!'); } @@ -543,31 +787,18 @@ class DdpClient { } void _onDone() { - _socket = null; if (_isTryToReconnect) { - printDebug( - 'Disconnect the socket due to "onDone" event on the websocket!', - ); - disconnect(); - printDebug( - 'ScheduleReconnect due to "onDone" event on the websocket!', - ); - _scheduleReconnect(); + _handleConnectionLost('The websocket was closed by the other side'); } else { - disconnect(); + _teardownConnection('The websocket was closed'); } } void _onError(dynamic error) { - _socket = null; if (_isTryToReconnect) { - printDebug('Disconnect due to "onError" event on the websocket!'); - disconnect(); - printDebug('ScheduleReconnect due to "onError" event on the websocket!'); - _scheduleReconnect(); + _handleConnectionLost('Websocket error: $error'); } else { - printDebug('Disconnect due to "onError" event on the websocket!'); - disconnect(); + _teardownConnection('Websocket error: $error'); } } } diff --git a/lib/src/meteor_client.dart b/lib/src/meteor_client.dart index ace0554..d246344 100644 --- a/lib/src/meteor_client.dart +++ b/lib/src/meteor_client.dart @@ -92,13 +92,25 @@ class MeteorClient { MeteorClient.connect( {required String url, bool debug = false, - userAgent = 'DartMeteor/4.0.0'}) { + userAgent = 'DartMeteor/4.1.0', + Duration? pingInterval, + Duration? pongTimeout, + Duration? maxRetryInterval, + Duration? stalenessThreshold}) { url = url.replaceFirst(RegExp(r'^http'), 'ws'); if (!url.endsWith('websocket')) { url = '${url.replaceFirst(RegExp(r'/$'), '')}/websocket'; } print('MeteorClient[$hashCode] - Make a connection to $url'); - connection = DdpClient(url: url, debug: debug, userAgent: userAgent); + connection = DdpClient( + url: url, + debug: debug, + userAgent: userAgent, + pingInterval: pingInterval, + pongTimeout: pongTimeout, + maxRetryInterval: maxRetryInterval, + stalenessThreshold: stalenessThreshold, + ); connection.status().listen((ddpStatus) { _statusSubject.add(ddpStatus); @@ -168,9 +180,12 @@ class MeteorClient { ..onError((dynamic error) {}) ..onDone(() {}); - connection.onReconnect((OnReconnectionCallback reconnectionCallback) { + // Awaited by the DdpClient before subscriptions are re-sent, so + // publications that depend on `this.userId` see the resumed login instead + // of an anonymous connection. + connection.onReconnect((OnReconnectionCallback reconnectionCallback) async { print('MeteorClient[$hashCode] - connection.onReconnect()'); - _loginWithExistingToken().catchError((error) { + await _loginWithExistingToken().catchError((error) { return null; }); }); @@ -345,6 +360,33 @@ class MeteorClient { connection.disconnect(); } + /// Tell the client that the host application went to the background. + /// + /// Flutter apps should call this from a [WidgetsBindingObserver] when the + /// lifecycle state becomes `paused`, `inactive`, `detached` or `hidden`. See + /// the "App lifecycle" section of the README. + void notifyAppPaused() { + connection.notifyAppPaused(); + } + + /// Tell the client that the host application returned to the foreground. + /// + /// A device that was asleep wakes up with timers that never fired and a + /// socket the server may already have discarded. This checks by wall clock + /// how long the connection has actually been silent and reconnects + /// immediately if it is stale, instead of waiting for the next ping to time + /// out. Call it when the lifecycle state becomes `resumed`. + void notifyAppResumed() { + connection.notifyAppResumed(); + } + + /// Verify the connection is still alive right now, reconnecting if it is + /// not. [notifyAppResumed] calls this for you; call it directly if the app + /// learns some other way that connectivity may have changed. + void checkLiveness() { + connection.checkLiveness(); + } + // =========================================================== // Accounts diff --git a/pubspec.yaml b/pubspec.yaml index 0cf8eb5..9de17d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: dart_meteor description: This library make connection between meteor backend and flutter app easily. Design to work seamlessly with StreamBuilder and FutureBuilder. -version: 4.0.0 +version: 4.1.0 homepage: https://github.com/tanutapi/dart_meteor environment: diff --git a/test/dart_meteor_test.dart b/test/dart_meteor_test.dart index 970c96a..17710e5 100644 --- a/test/dart_meteor_test.dart +++ b/test/dart_meteor_test.dart @@ -7,6 +7,28 @@ import 'package:test/test.dart'; var url = 'ws://127.0.0.1:3000'; +/// `login` resolves as soon as the method returns, but the user document +/// arrives afterwards as a separate DDP `added` message on the `users` +/// collection - measured at ~30ms against a local Meteor 3.x server. That +/// matches Meteor's own semantics, where `userId` is available immediately +/// and `user()` is reactive, so a test must await the document rather than +/// read it synchronously. +Future _awaitUserDocument(MeteorClient meteor) async { + await meteor + .user() + .firstWhere((user) => user != null) + .timeout(Duration(seconds: 10)); +} + +/// The mirror of [_awaitUserDocument]: after logout the user stream drops back +/// to null on the next event, not synchronously. +Future _awaitNoUserDocument(MeteorClient meteor) async { + await meteor + .user() + .firstWhere((user) => user == null) + .timeout(Duration(seconds: 10)); +} + void main() { group('Environment', () { var meteor = MeteorClient.connect( @@ -222,9 +244,11 @@ void main() { print('MeteorClientLoginResult: $result'); print('UserID: ${meteor.userIdCurrentValue()}'); expect(meteor.userIdCurrentValue(), isNotNull); + await _awaitUserDocument(meteor); expect(meteor.userCurrentValue(), isNotNull); await meteor.logout(); expect(meteor.userIdCurrentValue(), isNull); + await _awaitNoUserDocument(meteor); expect(meteor.userCurrentValue(), isNull); }); @@ -233,11 +257,13 @@ void main() { print('MeteorClientLoginResult: $result1'); print('UserID: ${meteor.userIdCurrentValue()}'); expect(meteor.userIdCurrentValue(), isNotNull); + await _awaitUserDocument(meteor); expect(meteor.userCurrentValue(), isNotNull); var result2 = await meteor.logoutOtherClients(); expect(result2, isNotNull); expect(meteor.userIdCurrentValue(), isNotNull); + await _awaitUserDocument(meteor); expect(meteor.userCurrentValue(), isNotNull); // Must be the same userId expect(result2.userId, result1.userId); @@ -314,7 +340,10 @@ void main() { args: [], onReady: () { print('onReady is called.'); - completer.complete(true); + // onReady fires again if the subscription is re-established. + if (!completer.isCompleted) { + completer.complete(true); + } }, ); await Future.delayed(Duration(seconds: 5)); @@ -394,7 +423,10 @@ void main() { meteor.collection('messages').listen((value) { var msgCnt = value.values.toList().length; print('resume subscription, message count: $msgCnt'); - if (msgCnt == 2) { + // The collection stream can emit the same count more than once (a + // re-subscribe replays `added`, and messages leak in from earlier + // tests), so completing unguarded throws "Future already completed". + if (msgCnt == 2 && !completer.isCompleted) { completer.complete(true); } }); @@ -445,7 +477,7 @@ void main() { var assets = meteor.collectionCurrentValue('assets'); if (username == 'user2' && assets!.length == 1) { assets.forEach((k, v) { - if (v['owner'] == 'user2') { + if (v['owner'] == 'user2' && !completer.isCompleted) { completer.complete(true); } }); @@ -482,7 +514,7 @@ void main() { var assets = meteor.collectionCurrentValue('assets'); if (username == 'user2' && assets!.length == 2) { assets.forEach((k, v) { - if (v['owner'] == 'user2') { + if (v['owner'] == 'user2' && !completer.isCompleted) { completer.complete(true); } }); diff --git a/test/ddp_mock_server_test.dart b/test/ddp_mock_server_test.dart index cc58148..afe5554 100644 --- a/test/ddp_mock_server_test.dart +++ b/test/ddp_mock_server_test.dart @@ -7,235 +7,11 @@ library; import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'package:crypto/crypto.dart'; import 'package:dart_meteor/dart_meteor.dart'; import 'package:test/test.dart'; -/// A minimal in-process DDP server speaking protocol version "1". -class MockDdpServer { - HttpServer? _httpServer; - final List _sockets = []; - int get port => _httpServer!.port; - - /// Documents published by the `items` publication. - final Map> itemsCollection = {}; - - /// Digest expected from loginWithPassword for user1/password1. - static final String user1Digest = - sha256.convert(utf8.encode('password1')).toString(); - - /// The last login request received, for asserting on the wire format. - Map? lastLoginRequest; - - /// The last method message received, for asserting on the wire format. - Map? lastMethodMessage; - - Future start() async { - _httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - _httpServer!.listen((HttpRequest req) async { - if (WebSocketTransformer.isUpgradeRequest(req)) { - var socket = await WebSocketTransformer.upgrade(req); - _sockets.add(socket); - // Meteor sends server_id as the very first message on the socket. - socket.add(json.encode({'server_id': '0'})); - socket.listen((data) => _onMessage(socket, data), - onDone: () => _sockets.remove(socket)); - } else { - req.response.statusCode = HttpStatus.notFound; - await req.response.close(); - } - }); - } - - Future stop() async { - for (var socket in List.from(_sockets)) { - await socket.close(); - } - _sockets.clear(); - await _httpServer?.close(force: true); - _httpServer = null; - } - - void closeAllSockets() { - for (var socket in List.from(_sockets)) { - socket.close(); - } - _sockets.clear(); - } - - void _send(WebSocket socket, Map msg) { - socket.add(json.encode(msg)); - } - - void _onMessage(WebSocket socket, dynamic data) { - var msg = json.decode(data) as Map; - switch (msg['msg']) { - case 'connect': - if ((msg['version'] == '1') && (msg['support'] as List).contains('1')) { - _send(socket, {'msg': 'connected', 'session': 'mock-session-id'}); - } else { - _send(socket, {'msg': 'failed', 'version': '1'}); - } - break; - case 'ping': - _send(socket, {'msg': 'pong', if (msg['id'] != null) 'id': msg['id']}); - break; - case 'pong': - break; - case 'method': - _handleMethod(socket, msg); - break; - case 'sub': - _handleSub(socket, msg); - break; - case 'unsub': - _send(socket, {'msg': 'nosub', 'id': msg['id']}); - break; - } - } - - void _handleMethod(WebSocket socket, Map msg) { - lastMethodMessage = msg; - var id = msg['id']; - var params = msg['params'] as List? ?? []; - switch (msg['method']) { - case 'login': - var loginData = params.isNotEmpty - ? params[0] as Map - : {}; - lastLoginRequest = loginData; - var password = loginData['password']; - var resume = loginData['resume']; - var validPassword = password is Map && - password['algorithm'] == 'sha-256' && - password['digest'] == user1Digest; - var validResume = resume == 'valid-resume-token'; - if (validPassword || validResume) { - _send(socket, { - 'msg': 'result', - 'id': id, - 'result': { - 'id': 'user1-id', - 'token': 'valid-resume-token', - 'tokenExpires': { - '\$date': DateTime.now() - .add(Duration(days: 90)) - .millisecondsSinceEpoch - }, - }, - }); - _send(socket, { - 'msg': 'updated', - 'methods': [id] - }); - } else { - _send(socket, { - 'msg': 'result', - 'id': id, - 'error': { - 'isClientSafe': true, - 'error': 403, - 'reason': 'Incorrect password', - 'message': 'Incorrect password [403]', - 'errorType': 'Meteor.Error', - }, - }); - } - break; - case 'echo': - _send(socket, {'msg': 'result', 'id': id, 'result': params}); - _send(socket, { - 'msg': 'updated', - 'methods': [id] - }); - break; - case 'methodThatReturnNumber': - _send(socket, {'msg': 'result', 'id': id, 'result': 42}); - _send(socket, { - 'msg': 'updated', - 'methods': [id] - }); - break; - case 'methodThatReturnDate': - _send(socket, { - 'msg': 'result', - 'id': id, - 'result': { - 'createdAt': {'\$date': 1598804210504}, - }, - }); - _send(socket, { - 'msg': 'updated', - 'methods': [id] - }); - break; - case 'methodThatThrowError': - _send(socket, { - 'msg': 'result', - 'id': id, - 'error': { - 'isClientSafe': true, - 'error': 500, - 'reason': 'This is an error', - 'message': 'This is an error [500]', - 'errorType': 'Meteor.Error', - }, - }); - break; - default: - _send(socket, { - 'msg': 'result', - 'id': id, - 'error': { - 'isClientSafe': true, - 'error': 404, - 'reason': "Method '${msg['method']}' not found", - 'errorType': 'Meteor.Error', - }, - }); - } - } - - void _handleSub(WebSocket socket, Map msg) { - var id = msg['id']; - switch (msg['name']) { - case 'items': - itemsCollection.forEach((docId, fields) { - _send(socket, { - 'msg': 'added', - 'collection': 'items', - 'id': docId, - 'fields': fields, - }); - }); - _send(socket, { - 'msg': 'ready', - 'subs': [id] - }); - break; - default: - _send(socket, { - 'msg': 'nosub', - 'id': id, - 'error': { - 'isClientSafe': true, - 'error': 404, - 'reason': "Subscription '${msg['name']}' not found", - 'errorType': 'Meteor.Error', - }, - }); - } - } - - /// Push a change on the `items` collection to every connected client. - void broadcast(Map msg) { - for (var socket in _sockets) { - _send(socket, msg); - } - } -} +import 'mock_ddp_server.dart'; Future _waitForConnected(MeteorClient meteor) async { await meteor diff --git a/test/lifecycle_test.dart b/test/lifecycle_test.dart new file mode 100644 index 0000000..802a13e --- /dev/null +++ b/test/lifecycle_test.dart @@ -0,0 +1,248 @@ +/// Regression tests for the connection lifecycle: what happens when a device +/// sleeps, when the socket goes stale without closing, when the server is +/// unreachable, and when the app is explicitly disconnected. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_meteor/dart_meteor.dart'; +import 'package:test/test.dart'; + +import 'mock_ddp_server.dart'; + +/// Short timings so the ping/pong paths can be exercised in a test run +/// instead of the 20s/5s production defaults. +MeteorClient _connectClient(int port) => MeteorClient.connect( + url: 'ws://127.0.0.1:$port', + pingInterval: Duration(milliseconds: 300), + pongTimeout: Duration(milliseconds: 300), + maxRetryInterval: Duration(seconds: 2), + stalenessThreshold: Duration(milliseconds: 600), + ); + +Future _waitForConnected(MeteorClient meteor, + {Duration timeout = const Duration(seconds: 10)}) async { + await meteor + .status() + .firstWhere((s) => s.status == DdpConnectionStatusValues.connected) + .timeout(timeout); +} + +Future _waitForDisconnected(MeteorClient meteor, + {Duration timeout = const Duration(seconds: 10)}) async { + await meteor + .status() + .firstWhere((s) => s.status != DdpConnectionStatusValues.connected) + .timeout(timeout); +} + +void main() { + group('connection lifecycle', () { + late MockDdpServer server; + late MeteorClient meteor; + + setUp(() async { + server = MockDdpServer(); + server.itemsCollection['doc1'] = {'title': 'First'}; + await server.start(); + meteor = _connectClient(server.port); + await _waitForConnected(meteor); + }); + + tearDown(() async { + meteor.disconnect(); + await server.stop(); + }); + + test('recovers when the server stops answering pings', () async { + // The socket stays open but the server goes silent - exactly what a + // client sees when the device slept and the server gave up on it. + server.respondToPings = false; + await _waitForDisconnected(meteor); + + // The client must not give up: once the server is healthy again it has + // to reconnect on its own, with no manual reconnect() call. + server.respondToPings = true; + await _waitForConnected(meteor); + expect(await meteor.call('methodThatReturnNumber'), 42); + }, timeout: Timeout(Duration(seconds: 30))); + + test('an in-flight method call fails instead of hanging when the socket drops', + () async { + server.silentMethods.add('neverReturns'); + var future = meteor.call('neverReturns'); + await Future.delayed(Duration(milliseconds: 100)); + server.closeAllSockets(); + + await expectLater( + future.timeout(Duration(seconds: 5)), + throwsA(isA()), + ); + }, timeout: Timeout(Duration(seconds: 30))); + + test('a method call made while offline fails immediately', () async { + meteor.disconnect(); + await expectLater( + meteor.call('methodThatReturnNumber').timeout(Duration(seconds: 5)), + throwsA(isA()), + ); + }); + + test('subscriptions are re-sent only after the login is resumed', () async { + await meteor.loginWithPassword('user1', 'password1'); + meteor.subscribe('items'); + await Future.delayed(Duration(milliseconds: 200)); + + var socketsBefore = server.connectionCount; + server.closeAllSockets(); + await _waitForDisconnected(meteor); + await _waitForConnected(meteor); + await Future.delayed(Duration(milliseconds: 300)); + + expect(server.connectionCount, greaterThan(socketsBefore)); + var msgs = server.messagesOnLatestSocket; + var loginIndex = msgs.indexWhere( + (m) => m['msg'] == 'method' && m['method'] == 'login'); + var subIndex = msgs.indexWhere((m) => m['msg'] == 'sub'); + expect(loginIndex, isNonNegative, + reason: 'the resume login must be sent on the new socket'); + expect(subIndex, isNonNegative, + reason: 'the subscription must be re-sent on the new socket'); + expect(loginIndex, lessThan(subIndex), + reason: 'login must precede the re-subscribe so publications ' + 'see this.userId'); + }, timeout: Timeout(Duration(seconds: 30))); + + test('notifyAppResumed reconnects a client whose socket died while asleep', + () async { + meteor.notifyAppPaused(); + server.closeAllSockets(); + await _waitForDisconnected(meteor); + + meteor.notifyAppResumed(); + await _waitForConnected(meteor, timeout: Duration(seconds: 5)); + expect(await meteor.call('methodThatReturnNumber'), 42); + }, timeout: Timeout(Duration(seconds: 30))); + + test('notifyAppResumed tears down a socket that went stale while suspended', + () async { + meteor.notifyAppPaused(); + // Server goes silent; while paused the client does not act on it. + server.respondToPings = false; + await Future.delayed(Duration(seconds: 1)); + expect((await meteor.status().first).status, + DdpConnectionStatusValues.connected, + reason: 'a paused app should not tear down its connection'); + + var socketsBefore = server.connectionCount; + server.respondToPings = true; + meteor.notifyAppResumed(); + + // The wall-clock gap exceeds the staleness threshold, so the stale + // socket must be replaced rather than trusted: a brand new socket has to + // appear at the server. + var deadline = DateTime.now().add(Duration(seconds: 10)); + while (server.connectionCount == socketsBefore && + DateTime.now().isBefore(deadline)) { + await Future.delayed(Duration(milliseconds: 50)); + } + expect(server.connectionCount, greaterThan(socketsBefore), + reason: 'a stale socket must be replaced on resume'); + await _waitForConnected(meteor, timeout: Duration(seconds: 10)); + expect(await meteor.call('methodThatReturnNumber'), 42); + }, timeout: Timeout(Duration(seconds: 30))); + }); + + group('reconnect policy', () { + test('a user-initiated disconnect is not undone by a pending retry', + () async { + var server = MockDdpServer(); + await server.start(); + var meteor = _connectClient(server.port); + await _waitForConnected(meteor); + + server.closeAllSockets(); + await _waitForDisconnected(meteor); + var socketsAtDisconnect = server.connectionCount; + meteor.disconnect(); + + await Future.delayed(Duration(seconds: 3)); + var status = await meteor.status().first; + expect(status.status, DdpConnectionStatusValues.offline); + expect(server.connectionCount, socketsAtDisconnect, + reason: 'no new socket may be opened after an explicit disconnect'); + + await server.stop(); + }, timeout: Timeout(Duration(seconds: 30))); + + test('an unreachable server backs off instead of spinning', () async { + // Accepts TCP but refuses the websocket upgrade, so every attempt fails + // the same way and is countable. + var attempts = 0; + var httpServer = + await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + httpServer.listen((req) async { + attempts++; + req.response.statusCode = HttpStatus.notFound; + await req.response.close(); + }); + + var meteor = _connectClient(httpServer.port); + var retryTimes = []; + var sub = meteor.status().listen((s) { + if (s.status == DdpConnectionStatusValues.waiting) { + retryTimes.add(s.retryTime.inMilliseconds); + } + }); + + await Future.delayed(Duration(seconds: 4)); + await sub.cancel(); + meteor.disconnect(); + await httpServer.close(force: true); + + // A tight loop would rack up hundreds of attempts in 4 seconds. + expect(attempts, lessThan(6), + reason: 'reconnects must back off, not spin'); + expect(retryTimes.length, greaterThan(1), + reason: 'the client must keep retrying'); + // The first entry is the client's initial `waiting` state, not a retry; + // what matters is that the interval climbs to the configured ceiling. + expect(retryTimes.reduce((a, b) => a > b ? a : b), + greaterThanOrEqualTo(2000), + reason: 'the backoff interval must grow up to maxRetryInterval'); + expect(retryTimes.last, greaterThanOrEqualTo(retryTimes.first), + reason: 'the backoff interval must not shrink back to zero'); + }, timeout: Timeout(Duration(seconds: 30))); + + test('connecting to an unreachable port does not throw unhandled errors', + () async { + var errors = []; + await runZonedGuarded(() async { + // Nothing is listening here. + var meteor = MeteorClient.connect( + url: 'ws://127.0.0.1:1', + pingInterval: Duration(milliseconds: 300), + pongTimeout: Duration(milliseconds: 300), + maxRetryInterval: Duration(seconds: 1), + ); + await Future.delayed(Duration(seconds: 3)); + var status = await meteor.status().first; + expect( + status.status, + anyOf( + DdpConnectionStatusValues.waiting, + DdpConnectionStatusValues.offline, + DdpConnectionStatusValues.connecting, + DdpConnectionStatusValues.failed, + )); + meteor.disconnect(); + }, (error, stack) { + errors.add(error); + }); + await Future.delayed(Duration(milliseconds: 500)); + expect(errors, isEmpty, + reason: 'a failed connection must not escape as an unhandled error'); + }, timeout: Timeout(Duration(seconds: 30))); + }); +} diff --git a/test/mock_ddp_server.dart b/test/mock_ddp_server.dart new file mode 100644 index 0000000..cfa27da --- /dev/null +++ b/test/mock_ddp_server.dart @@ -0,0 +1,280 @@ +/// A minimal in-process DDP server speaking protocol version "1", matching +/// the behavior of a Meteor 3.x server (tested against Meteor 3.5.1) over +/// `/websocket`. Shared by the protocol and lifecycle test suites so neither +/// needs a real Meteor server or docker. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +class MockDdpServer { + HttpServer? _httpServer; + final List _sockets = []; + int? _boundPort; + + /// The port the server is (or last was) bound to. Stable across a + /// [stop]/[start] cycle so tests can simulate a server outage. + int get port => _httpServer?.port ?? _boundPort!; + + /// Documents published by the `items` publication. + final Map> itemsCollection = {}; + + /// Digest expected from loginWithPassword for user1/password1. + static final String user1Digest = + sha256.convert(utf8.encode('password1')).toString(); + + /// The last login request received, for asserting on the wire format. + Map? lastLoginRequest; + + /// The last method message received, for asserting on the wire format. + Map? lastMethodMessage; + + /// When false the server ignores client `ping` messages, simulating a + /// connection that has gone stale without the socket being closed - what a + /// sleeping device sees on wake. + bool respondToPings = true; + + /// Methods named here are accepted but never answered, so the client is + /// left with an in-flight call. + final Set silentMethods = {}; + + /// How many websocket connections have been accepted over this server's + /// lifetime, including across restarts. + int connectionCount = 0; + + /// Every message received, grouped by the socket it arrived on. Index 0 is + /// the first connection. Used to assert on message ordering after a + /// reconnect. + final List>> messagesBySocket = []; + + /// Messages received on the most recently accepted socket. + List> get messagesOnLatestSocket => + messagesBySocket.isEmpty ? const [] : messagesBySocket.last; + + Future start({int? port}) async { + _httpServer = + await HttpServer.bind(InternetAddress.loopbackIPv4, port ?? 0); + _boundPort = _httpServer!.port; + _httpServer!.listen((HttpRequest req) async { + if (WebSocketTransformer.isUpgradeRequest(req)) { + var socket = await WebSocketTransformer.upgrade(req); + _sockets.add(socket); + connectionCount++; + var inbox = >[]; + messagesBySocket.add(inbox); + // Meteor sends server_id as the very first message on the socket. + socket.add(json.encode({'server_id': '0'})); + socket.listen((data) => _onMessage(socket, inbox, data), + onDone: () => _sockets.remove(socket)); + } else { + req.response.statusCode = HttpStatus.notFound; + await req.response.close(); + } + }); + } + + Future stop() async { + for (var socket in List.from(_sockets)) { + await socket.close(); + } + _sockets.clear(); + await _httpServer?.close(force: true); + _httpServer = null; + } + + void closeAllSockets() { + for (var socket in List.from(_sockets)) { + socket.close(); + } + _sockets.clear(); + } + + void _send(WebSocket socket, Map msg) { + // A message can be in flight when the test closes the socket underneath + // us; a real server would just drop it. + if (socket.readyState != WebSocket.open) { + return; + } + try { + socket.add(json.encode(msg)); + } on StateError { + // Sink closed between the check and the write. + } + } + + void _onMessage( + WebSocket socket, List> inbox, dynamic data) { + var msg = json.decode(data) as Map; + inbox.add(msg); + switch (msg['msg']) { + case 'connect': + if ((msg['version'] == '1') && (msg['support'] as List).contains('1')) { + _send(socket, {'msg': 'connected', 'session': 'mock-session-id'}); + } else { + _send(socket, {'msg': 'failed', 'version': '1'}); + } + break; + case 'ping': + if (respondToPings) { + _send(socket, {'msg': 'pong', if (msg['id'] != null) 'id': msg['id']}); + } + break; + case 'pong': + break; + case 'method': + _handleMethod(socket, msg); + break; + case 'sub': + _handleSub(socket, msg); + break; + case 'unsub': + _send(socket, {'msg': 'nosub', 'id': msg['id']}); + break; + } + } + + void _handleMethod(WebSocket socket, Map msg) { + lastMethodMessage = msg; + var id = msg['id']; + var params = msg['params'] as List? ?? []; + if (silentMethods.contains(msg['method'])) { + return; + } + switch (msg['method']) { + case 'login': + var loginData = params.isNotEmpty + ? params[0] as Map + : {}; + lastLoginRequest = loginData; + var password = loginData['password']; + var resume = loginData['resume']; + var validPassword = password is Map && + password['algorithm'] == 'sha-256' && + password['digest'] == user1Digest; + var validResume = resume == 'valid-resume-token'; + if (validPassword || validResume) { + _send(socket, { + 'msg': 'result', + 'id': id, + 'result': { + 'id': 'user1-id', + 'token': 'valid-resume-token', + 'tokenExpires': { + '\$date': DateTime.now() + .add(Duration(days: 90)) + .millisecondsSinceEpoch + }, + }, + }); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + } else { + _send(socket, { + 'msg': 'result', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 403, + 'reason': 'Incorrect password', + 'message': 'Incorrect password [403]', + 'errorType': 'Meteor.Error', + }, + }); + } + break; + case 'echo': + _send(socket, {'msg': 'result', 'id': id, 'result': params}); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + break; + case 'methodThatReturnNumber': + _send(socket, {'msg': 'result', 'id': id, 'result': 42}); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + break; + case 'methodThatReturnDate': + _send(socket, { + 'msg': 'result', + 'id': id, + 'result': { + 'createdAt': {'\$date': 1598804210504}, + }, + }); + _send(socket, { + 'msg': 'updated', + 'methods': [id] + }); + break; + case 'methodThatThrowError': + _send(socket, { + 'msg': 'result', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 500, + 'reason': 'This is an error', + 'message': 'This is an error [500]', + 'errorType': 'Meteor.Error', + }, + }); + break; + default: + _send(socket, { + 'msg': 'result', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 404, + 'reason': "Method '${msg['method']}' not found", + 'errorType': 'Meteor.Error', + }, + }); + } + } + + void _handleSub(WebSocket socket, Map msg) { + var id = msg['id']; + switch (msg['name']) { + case 'items': + itemsCollection.forEach((docId, fields) { + _send(socket, { + 'msg': 'added', + 'collection': 'items', + 'id': docId, + 'fields': fields, + }); + }); + _send(socket, { + 'msg': 'ready', + 'subs': [id] + }); + break; + default: + _send(socket, { + 'msg': 'nosub', + 'id': id, + 'error': { + 'isClientSafe': true, + 'error': 404, + 'reason': "Subscription '${msg['name']}' not found", + 'errorType': 'Meteor.Error', + }, + }); + } + } + + /// Push a change on the `items` collection to every connected client. + void broadcast(Map msg) { + for (var socket in _sockets) { + _send(socket, msg); + } + } +}