diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index b36474f..dca0f4f 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -7,30 +7,30 @@ 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: 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 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..96da65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,59 @@ +# 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). +- 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..b92df4e 100644 --- a/README.md +++ b/README.md @@ -1,147 +1,89 @@ -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-beta.1 -Using the `web_socket_channel` to make this package supports Dart VM, iOS, Android, and Web. Thank you to mel-mouk. +- **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 3.1.0 ## -Bump the SDK version to <4.0.0 and update dependencies. +## Features -## 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. +- Method calls with `Future`-based results +- Subscriptions and reactive collections as `Stream`s +- Accounts: login with password/token, logout, password management +- 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 -## Change on 2.0.0 ## +## Installation -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. +Add the package to your `pubspec.yaml`: -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.1.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'), + ); + }, + ), + ], ), ), ); @@ -149,83 +91,77 @@ class _MyAppState extends State { } ``` -## Making a method call to your server +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 -Making a method call to your server returns a Future. You MUST handle `catchError` to prevent your app from crashing if something goes wrong. +`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]. - -## 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. +## Subscriptions and collections -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'); }, ); @@ -233,45 +169,202 @@ 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 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 + +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 + `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]. @@ -279,3 +372,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 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/.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/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/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 6fff8d5..efd11e4 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,28 +1,14 @@ -name: dart_meteor_example_app -description: A new Flutter project. - -# 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: ../ @@ -30,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: ^5.0.0 - + 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 0000000..8aaa46a Binary files /dev/null and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/example/web/icons/Icon-512.png differ diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ 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" + } + ] +} 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..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'; @@ -79,8 +104,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 = @@ -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: PONG_WITHIN_SEC), () { - 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: PING_SEC_INTERVAL), (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!'); } @@ -532,7 +776,6 @@ class DdpClient { } else if (k == '\$date') { if (parent != null && field != null) { parent[field] = DateTime.fromMillisecondsSinceEpoch(v); - return parent[field]; } } }); @@ -544,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 b120d02..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/2.0.4'}) { + 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 24f8a5b..9de17d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,17 +1,25 @@ 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.1.0 homepage: https://github.com/tanutapi/dart_meteor environment: - sdk: '>=2.12.0 <4.0.0' + sdk: '>=3.6.0 <4.0.0' + +platforms: + android: + ios: + linux: + macos: + web: + windows: 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..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); } }); @@ -475,15 +507,14 @@ 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) { 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 new file mode 100644 index 0000000..afe5554 --- /dev/null +++ b/test/ddp_mock_server_test.dart @@ -0,0 +1,206 @@ +/// 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 'package:dart_meteor/dart_meteor.dart'; +import 'package:test/test.dart'; + +import 'mock_ddp_server.dart'; + +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))); + }); +} 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); + } + } +}