[{"data":1,"prerenderedAt":86},["ShallowReactive",2],{"tutorial-chat-in-tempo-reale-in-flutter-con-websocket-e-web-socket-channel":3,"comments-tutorial-chat-in-tempo-reale-in-flutter-con-websocket-e-web-socket-channel":85},{"id":4,"title":5,"slug":6,"excerpt":7,"intro":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"video":16,"difficulty":20,"estimated_minutes":21,"flutter_version":22,"status":23,"published_at":24,"meta_title":25,"meta_description":26,"category":27,"author":31,"steps":33},127,"Chat in tempo reale in Flutter con WebSocket e web_socket_channel","chat-in-tempo-reale-in-flutter-con-websocket-e-web-socket-channel","Costruisci un client WebSocket robusto in Flutter: connessione con web_socket_channel, messaggi JSON tipizzati, heartbeat, riconnessione automatica con backoff e UI reattiva con StreamBuilder.","Le API REST vanno benissimo per leggere e scrivere dati su richiesta, ma quando serve il **tempo reale** (chat, notifiche live, quotazioni, posizioni GPS condivise) il polling continuo diventa costoso e lento. La soluzione è il protocollo **WebSocket**: un canale bidirezionale persistente tra app e server.\n\nIn questo tutorial costruiamo un client WebSocket *production-ready* in Flutter usando il pacchetto ufficiale [`web_socket_channel`](https:\u002F\u002Fpub.dev\u002Fpackages\u002Fweb_socket_channel). Non ci limiteremo ad aprire una connessione: affronteremo i problemi che si incontrano davvero in produzione, cioè la caduta della rete, i server che chiudono le connessioni inattive e la gestione del ciclo di vita.\n\nAl termine avrai:\n\n- un servizio `WebSocketService` riutilizzabile con stream broadcast di messaggi e di stato;\n- **heartbeat** (ping applicativo) per tenere viva la connessione;\n- **riconnessione automatica** con backoff esponenziale;\n- messaggi JSON serializzati in modelli Dart;\n- una UI di chat reattiva basata su `StreamBuilder`.\n\n> Per provare subito useremo l'echo server pubblico `wss:\u002F\u002Fecho.websocket.events`, che rimanda indietro tutto ciò che gli invii: perfetto per testare senza backend.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Ftutorials\u002F56f6c889-a584-4739-bf67-3e9e4e784aca.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1623294361500-e0fe29bf7ee0?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTIyMTE1MTF8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Hikarinoshita Hikari","https:\u002F\u002Funsplash.com\u002F@hikarinoshita","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fa-black-and-white-cat-staring-at-something-vIogxi3pgMM","https:\u002F\u002Fwww.youtube.com\u002Fwatch?v=1XRaht8DO5k",{"youtube_id":17,"duration_seconds":18,"published_at":19},"1XRaht8DO5k",144,"2026-10-17T08:55:52+00:00","intermediate",45,"3.x","published","2026-10-17T04:31:52+00:00","WebSocket in Flutter: chat realtime step by step","Guida pratica a web_socket_channel in Flutter: connessione WebSocket, messaggi JSON, heartbeat, riconnessione automatica con backoff e UI con StreamBuilder.",{"id":28,"name":29,"slug":30},1,"Guide","guide",{"id":28,"name":32},"Flutter Bot",[34,42,50,57,64,71,78],{"id":35,"position":28,"title":36,"body":37,"code_snippet":38,"code_language":39,"expected_result":40,"demo_url":41,"video_url":41},848,"Installare le dipendenze e configurare i permessi","Aggiungi `web_socket_channel` al progetto. Il pacchetto è mantenuto dal team Dart e funziona su Android, iOS, web, desktop (su web usa internamente le WebSocket del browser).\n\nDa terminale:\n\n```bash\nflutter pub add web_socket_channel\n```\n\nPoi verifica i permessi di rete:\n\n- **Android**: apri `android\u002Fapp\u002Fsrc\u002Fmain\u002FAndroidManifest.xml` e assicurati che sia presente `\u003Cuses-permission android:name=\"android.permission.INTERNET\"\u002F>` **fuori** dal tag `\u003Capplication>`.\n- **macOS**: in `macos\u002FRunner\u002FDebugProfile.entitlements` e `Release.entitlements` serve `com.apple.security.network.client` a `true`.\n- **iOS**: nessuna configurazione necessaria per `wss:\u002F\u002F`. Se devi usare `ws:\u002F\u002F` in chiaro (solo in sviluppo) dovrai configurare `NSAppTransportSecurity`.\n\n> Usa sempre `wss:\u002F\u002F` (TLS) in produzione: `ws:\u002F\u002F` trasmette i dati in chiaro e viene bloccato dalle policy di sicurezza delle piattaforme.","# pubspec.yaml (estratto)\ndependencies:\n  flutter:\n    sdk: flutter\n  web_socket_channel: ^3.0.1","yaml","Il comando `flutter pub get` termina senza errori e `web_socket_channel` compare tra le dipendenze del progetto.",null,{"id":43,"position":44,"title":45,"body":46,"code_snippet":47,"code_language":48,"expected_result":49,"demo_url":41,"video_url":41},849,2,"Creare il servizio WebSocket con stream broadcast","Non colleghiamo il canale direttamente ai widget: incapsuliamo tutto in un servizio. Questo ci permette di esporre **stream broadcast** (ascoltabili da più widget), di centralizzare la logica di riconnessione e di testare il codice senza UI.\n\nPunti chiave:\n\n- `WebSocketChannel.connect()` apre il canale; `channel.ready` è un `Future` che completa quando l'handshake è andato a buon fine (ed è il modo corretto per intercettare gli errori di connessione iniziale).\n- `channel.stream` è un **single-subscription stream**: possiamo ascoltarlo una sola volta. Per questo lo ri-pubblichiamo su uno `StreamController.broadcast()`.\n- Esponiamo anche uno stream di `SocketStatus`, utile per mostrare in UI un badge \"Connesso \u002F Riconnessione in corso\".\n- `onDone` viene invocato quando il server chiude la connessione; `onError` in caso di errori di rete. In entrambi i casi pianificheremo una riconnessione (prossimo passo).","import 'dart:async';\n\nimport 'package:web_socket_channel\u002Fstatus.dart' as ws_status;\nimport 'package:web_socket_channel\u002Fweb_socket_channel.dart';\n\nenum SocketStatus { disconnected, connecting, connected }\n\nclass WebSocketService {\n  WebSocketService(this.uri);\n\n  final Uri uri;\n\n  WebSocketChannel? _channel;\n  StreamSubscription\u003Cdynamic>? _subscription;\n\n  final _messages = StreamController\u003CString>.broadcast();\n  final _status = StreamController\u003CSocketStatus>.broadcast();\n\n  \u002F\u002F\u002F Messaggi grezzi in arrivo dal server.\n  Stream\u003CString> get messages => _messages.stream;\n\n  \u002F\u002F\u002F Stato della connessione, utile per la UI.\n  Stream\u003CSocketStatus> get status => _status.stream;\n\n  SocketStatus _current = SocketStatus.disconnected;\n  SocketStatus get currentStatus => _current;\n\n  bool _manuallyClosed = false;\n\n  void _setStatus(SocketStatus value) {\n    _current = value;\n    if (!_status.isClosed) _status.add(value);\n  }\n\n  Future\u003Cvoid> connect() async {\n    if (_current == SocketStatus.connecting ||\n        _current == SocketStatus.connected) {\n      return;\n    }\n\n    _manuallyClosed = false;\n    _setStatus(SocketStatus.connecting);\n\n    try {\n      final channel = WebSocketChannel.connect(uri);\n      await channel.ready; \u002F\u002F lancia se l'handshake fallisce\n      _channel = channel;\n      _setStatus(SocketStatus.connected);\n\n      _subscription = channel.stream.listen(\n        (data) => _messages.add(data.toString()),\n        onDone: _handleDisconnection,\n        onError: (Object error) => _handleDisconnection(),\n        cancelOnError: true,\n      );\n    } catch (_) {\n      _handleDisconnection();\n    }\n  }\n\n  void send(String message) {\n    if (_current != SocketStatus.connected) return;\n    _channel?.sink.add(message);\n  }\n\n  void _handleDisconnection() {\n    _setStatus(SocketStatus.disconnected);\n    \u002F\u002F La logica di retry arriva nel passo successivo.\n  }\n\n  Future\u003Cvoid> dispose() async {\n    _manuallyClosed = true;\n    await _subscription?.cancel();\n    await _channel?.sink.close(ws_status.normalClosure);\n    await _messages.close();\n    await _status.close();\n  }\n}","dart","Il servizio compila e, chiamando `connect()` verso `wss:\u002F\u002Fecho.websocket.events`, lo stato passa da `connecting` a `connected`.",{"id":51,"position":52,"title":53,"body":54,"code_snippet":55,"code_language":48,"expected_result":56,"demo_url":41,"video_url":41},850,3,"Aggiungere heartbeat e riconnessione con backoff esponenziale","Due problemi tipici del mondo reale:\n\n1. **Connessioni \"zombie\"**: proxy e load balancer chiudono le connessioni inattive dopo 30-60 secondi. La soluzione è un *heartbeat*: inviare periodicamente un messaggio di ping applicativo.\n2. **Cadute di rete**: passaggio Wi-Fi → 4G, tunnel, app in background. Serve riconnettersi, ma **senza martellare** il server: usiamo un backoff esponenziale con tetto massimo.\n\nAggiorniamo il servizio aggiungendo due `Timer` e un contatore di tentativi. Il contatore va azzerato a ogni connessione riuscita, altrimenti dopo la prima caduta resteresti sempre sul ritardo massimo.","import 'dart:async';\nimport 'dart:math';\n\n\u002F\u002F ... dentro la classe WebSocketService\n\n  Timer? _heartbeatTimer;\n  Timer? _reconnectTimer;\n  int _attempt = 0;\n\n  static const _heartbeatInterval = Duration(seconds: 20);\n  static const _maxBackoff = Duration(seconds: 30);\n\n  void _startHeartbeat() {\n    _heartbeatTimer?.cancel();\n    _heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {\n      if (_current == SocketStatus.connected) {\n        _channel?.sink.add('{\"type\":\"ping\"}');\n      }\n    });\n  }\n\n  void _stopTimers() {\n    _heartbeatTimer?.cancel();\n    _heartbeatTimer = null;\n    _reconnectTimer?.cancel();\n    _reconnectTimer = null;\n  }\n\n  void _scheduleReconnect() {\n    if (_manuallyClosed || _reconnectTimer != null) return;\n\n    \u002F\u002F 1s, 2s, 4s, 8s, 16s, 30s, 30s... con jitter per evitare picchi\n    final seconds = min(pow(2, _attempt).toInt(), _maxBackoff.inSeconds);\n    final jitterMs = Random().nextInt(500);\n    final delay = Duration(seconds: seconds, milliseconds: jitterMs);\n    _attempt++;\n\n    _reconnectTimer = Timer(delay, () {\n      _reconnectTimer = null;\n      connect();\n    });\n  }\n\n  void _handleDisconnection() {\n    _subscription?.cancel();\n    _subscription = null;\n    _channel = null;\n    _heartbeatTimer?.cancel();\n    _setStatus(SocketStatus.disconnected);\n    _scheduleReconnect();\n  }\n\n\u002F\u002F Nel metodo connect(), dopo `_setStatus(SocketStatus.connected);` aggiungi:\n\u002F\u002F   _attempt = 0;\n\u002F\u002F   _startHeartbeat();\n\u002F\u002F\n\u002F\u002F In dispose() ricordati di chiamare _stopTimers();","Spegnendo il Wi-Fi lo stato diventa `disconnected` e nei log vedi i tentativi di riconnessione distanziati progressivamente (1s, 2s, 4s...). Riattivando la rete la connessione torna `connected`.",{"id":58,"position":59,"title":60,"body":61,"code_snippet":62,"code_language":48,"expected_result":63,"demo_url":41,"video_url":41},851,4,"Definire il protocollo: messaggi JSON tipizzati","Scambiare stringhe grezze funziona solo nelle demo. In un'app reale si definisce un piccolo **protocollo applicativo**: ogni messaggio è un oggetto JSON con un campo `type` che ne determina il significato.\n\nCreiamo un modello `ChatMessage` con `fromJson`\u002F`toJson` scritti a mano (per progetti più grandi puoi generare il codice con `json_serializable`) e due metodi di comodo nel servizio per inviare e ricevere oggetti invece di stringhe.\n\nNota la gestione difensiva: un messaggio malformato o di tipo sconosciuto **non deve far crashare l'app**, quindi lo filtriamo restituendo `null` e usando `whereType\u003CChatMessage>()`.","import 'dart:convert';\n\nclass ChatMessage {\n  ChatMessage({\n    required this.id,\n    required this.author,\n    required this.text,\n    required this.sentAt,\n    this.mine = false,\n  });\n\n  final String id;\n  final String author;\n  final String text;\n  final DateTime sentAt;\n  final bool mine;\n\n  Map\u003CString, dynamic> toJson() => {\n        'type': 'message',\n        'id': id,\n        'author': author,\n        'text': text,\n        'sentAt': sentAt.toIso8601String(),\n      };\n\n  static ChatMessage? tryParse(String raw) {\n    try {\n      final json = jsonDecode(raw);\n      if (json is! Map\u003CString, dynamic>) return null;\n      if (json['type'] != 'message') return null; \u002F\u002F ignora ping\u002Fpong e altro\n      return ChatMessage(\n        id: json['id'] as String,\n        author: json['author'] as String? ?? 'anonimo',\n        text: json['text'] as String? ?? '',\n        sentAt: DateTime.tryParse(json['sentAt'] as String? ?? '') ??\n            DateTime.now(),\n      );\n    } catch (_) {\n      return null; \u002F\u002F payload non valido: lo scartiamo\n    }\n  }\n}\n\n\u002F\u002F Estensione del servizio: API tipizzata sopra lo stream di stringhe.\nextension ChatSocket on WebSocketService {\n  Stream\u003CChatMessage> get chatMessages =>\n      messages.map(ChatMessage.tryParse).whereType\u003CChatMessage>();\n\n  void sendChat(ChatMessage message) => send(jsonEncode(message.toJson()));\n}","Inviando un `ChatMessage` all'echo server ricevi indietro lo stesso oggetto già deserializzato in un'istanza di `ChatMessage`.",{"id":65,"position":66,"title":67,"body":68,"code_snippet":69,"code_language":48,"expected_result":70,"demo_url":41,"video_url":41},852,5,"Costruire la UI della chat con StreamBuilder","Ora colleghiamo tutto a un widget. Alcune accortezze importanti:\n\n- Il servizio va creato in `initState` e chiuso in `dispose`, altrimenti i timer di riconnessione continuano a girare anche dopo che la schermata è stata smontata (classico memory leak).\n- Accumuliamo i messaggi in una `List` locale sottoscrivendo lo stream: `StreamBuilder` da solo mostrerebbe solo l'ultimo evento.\n- Mostriamo un indicatore di stato nell'`AppBar` usando il secondo stream.\n- La `ListView` è `reverse: true` così i nuovi messaggi compaiono in basso senza dover gestire manualmente lo scroll.","class ChatPage extends StatefulWidget {\n  const ChatPage({super.key});\n\n  @override\n  State\u003CChatPage> createState() => _ChatPageState();\n}\n\nclass _ChatPageState extends State\u003CChatPage> {\n  late final WebSocketService _service;\n  final _controller = TextEditingController();\n  final _messages = \u003CChatMessage>[];\n  StreamSubscription\u003CChatMessage>? _sub;\n\n  @override\n  void initState() {\n    super.initState();\n    _service = WebSocketService(Uri.parse('wss:\u002F\u002Fecho.websocket.events'));\n    _sub = _service.chatMessages.listen((msg) {\n      if (!mounted) return;\n      setState(() => _messages.insert(0, msg));\n    });\n    _service.connect();\n  }\n\n  @override\n  void dispose() {\n    _sub?.cancel();\n    _service.dispose();\n    _controller.dispose();\n    super.dispose();\n  }\n\n  void _send() {\n    final text = _controller.text.trim();\n    if (text.isEmpty) return;\n    _service.sendChat(ChatMessage(\n      id: DateTime.now().microsecondsSinceEpoch.toString(),\n      author: 'io',\n      text: text,\n      sentAt: DateTime.now(),\n    ));\n    _controller.clear();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(\n        title: const Text('Chat realtime'),\n        bottom: PreferredSize(\n          preferredSize: const Size.fromHeight(24),\n          child: StreamBuilder\u003CSocketStatus>(\n            stream: _service.status,\n            initialData: _service.currentStatus,\n            builder: (context, snapshot) {\n              final status = snapshot.data ?? SocketStatus.disconnected;\n              final (label, color) = switch (status) {\n                SocketStatus.connected => ('Connesso', Colors.green),\n                SocketStatus.connecting => ('Connessione...', Colors.orange),\n                SocketStatus.disconnected => ('Offline', Colors.red),\n              };\n              return Container(\n                width: double.infinity,\n                color: color.withValues(alpha: 0.15),\n                padding: const EdgeInsets.symmetric(vertical: 4),\n                child: Text(label, textAlign: TextAlign.center),\n              );\n            },\n          ),\n        ),\n      ),\n      body: Column(\n        children: [\n          Expanded(\n            child: ListView.builder(\n              reverse: true,\n              padding: const EdgeInsets.all(12),\n              itemCount: _messages.length,\n              itemBuilder: (context, index) {\n                final msg = _messages[index];\n                return Align(\n                  alignment: Alignment.centerRight,\n                  child: Card(\n                    child: Padding(\n                      padding: const EdgeInsets.all(10),\n                      child: Text('${msg.author}: ${msg.text}'),\n                    ),\n                  ),\n                );\n              },\n            ),\n          ),\n          SafeArea(\n            child: Padding(\n              padding: const EdgeInsets.all(8),\n              child: Row(\n                children: [\n                  Expanded(\n                    child: TextField(\n                      controller: _controller,\n                      onSubmitted: (_) => _send(),\n                      decoration: const InputDecoration(\n                        hintText: 'Scrivi un messaggio...',\n                        border: OutlineInputBorder(),\n                      ),\n                    ),\n                  ),\n                  const SizedBox(width: 8),\n                  IconButton.filled(\n                    onPressed: _send,\n                    icon: const Icon(Icons.send),\n                  ),\n                ],\n              ),\n            ),\n          ),\n        ],\n      ),\n    );\n  }\n}","L'app mostra la chat: scrivendo un messaggio lo vedi comparire nella lista (rimandato indietro dall'echo server) e l'indicatore nell'AppBar segnala lo stato della connessione in tempo reale.",{"id":72,"position":73,"title":74,"body":75,"code_snippet":76,"code_language":48,"expected_result":77,"demo_url":41,"video_url":41},853,6,"Gestire il ciclo di vita dell'app e la coda di invio offline","Quando l'app va in background, il sistema operativo può sospendere la connessione: iOS la chiude quasi sempre dopo pochi secondi. Conviene quindi **disconnettere esplicitamente** in `paused` e **riconnettere** in `resumed`, così eviti socket in stato incerto e consumi di batteria.\n\nInoltre, se l'utente invia un messaggio mentre la connessione è caduta, oggi il messaggio viene perso silenziosamente. Aggiungiamo una piccola **coda in memoria** che viene svuotata al ritorno della connessione.\n\nPer osservare il ciclo di vita usiamo `AppLifecycleListener` (disponibile da Flutter 3.13) oppure il classico `WidgetsBindingObserver`.","\u002F\u002F 1) Coda di invio nel servizio\n  final List\u003CString> _outbox = [];\n  static const _maxOutbox = 50;\n\n  void send(String message) {\n    if (_current == SocketStatus.connected) {\n      _channel?.sink.add(message);\n    } else {\n      if (_outbox.length >= _maxOutbox) _outbox.removeAt(0);\n      _outbox.add(message);\n    }\n  }\n\n  void _flushOutbox() {\n    if (_current != SocketStatus.connected) return;\n    final pending = List\u003CString>.from(_outbox);\n    _outbox.clear();\n    for (final message in pending) {\n      _channel?.sink.add(message);\n    }\n  }\n  \u002F\u002F chiama _flushOutbox() subito dopo _startHeartbeat() in connect()\n\n  \u002F\u002F\u002F Chiusura volontaria: niente retry automatico.\n  Future\u003Cvoid> disconnect() async {\n    _manuallyClosed = true;\n    _stopTimers();\n    await _subscription?.cancel();\n    _subscription = null;\n    await _channel?.sink.close(ws_status.goingAway);\n    _channel = null;\n    _setStatus(SocketStatus.disconnected);\n  }\n\n\u002F\u002F 2) Ciclo di vita nella pagina\nlate final AppLifecycleListener _lifecycle;\n\n@override\nvoid initState() {\n  super.initState();\n  \u002F\u002F ... creazione del servizio\n  _lifecycle = AppLifecycleListener(\n    onPause: () => _service.disconnect(),\n    onResume: () => _service.connect(),\n  );\n}\n\n@override\nvoid dispose() {\n  _lifecycle.dispose();\n  \u002F\u002F ... resto del dispose\n  super.dispose();\n}","Mandando l'app in background la connessione si chiude in modo pulito; al ritorno in foreground si riapre e i messaggi scritti offline vengono inviati automaticamente.",{"id":79,"position":80,"title":81,"body":82,"code_snippet":83,"code_language":48,"expected_result":84,"demo_url":41,"video_url":41},854,7,"Testare il servizio e portarlo in produzione","Un servizio WebSocket va testato **senza rete reale**. Il pacchetto `web_socket_channel` include `StreamChannelController` (da `stream_channel`) per simulare un canale in memoria; in alternativa puoi iniettare una factory di connessione sostituibile nei test.\n\nRefactor minimo per la testabilità: trasforma la creazione del canale in una dipendenza iniettabile.\n\n```dart\ntypedef ChannelFactory = WebSocketChannel Function(Uri uri);\n\nclass WebSocketService {\n  WebSocketService(this.uri, {ChannelFactory? factory})\n      : _factory = factory ?? WebSocketChannel.connect;\n  final ChannelFactory _factory;\n  \u002F\u002F ... usa _factory(uri) al posto di WebSocketChannel.connect(uri)\n}\n```\n\n**Checklist prima del rilascio:**\n\n- Usa solo `wss:\u002F\u002F` e, se l'endpoint è critico, valuta il certificate pinning.\n- Autentica la connessione: passa il token in un header (`WebSocketChannel.connect` supporta gli header su mobile\u002Fdesktop) oppure invia un messaggio di `auth` come primo frame, dato che su Web gli header personalizzati non sono disponibili.\n- Limita la dimensione della coda offline e della lista messaggi in memoria.\n- Logga le transizioni di stato: ti salveranno ore di debug sui dispositivi degli utenti.\n- Se la logica di chat è condivisa tra più schermate, esponi il servizio con un provider (Riverpod, Provider o get_it) invece di crearlo nello `State`.","import 'dart:convert';\n\nimport 'package:flutter_test\u002Fflutter_test.dart';\nimport 'package:stream_channel\u002Fstream_channel.dart';\nimport 'package:web_socket_channel\u002Fweb_socket_channel.dart';\n\nvoid main() {\n  test('deserializza i messaggi di tipo message e ignora i ping', () async {\n    final controller = StreamChannelController\u003Cdynamic>(sync: true);\n    final service = WebSocketService(\n      Uri.parse('wss:\u002F\u002Ftest.local'),\n      factory: (_) => WebSocketChannel(controller.foreign),\n    );\n\n    await service.connect();\n\n    final received = \u003CChatMessage>[];\n    final sub = service.chatMessages.listen(received.add);\n\n    controller.local.sink.add(jsonEncode({'type': 'pong'}));\n    controller.local.sink.add(jsonEncode({\n      'type': 'message',\n      'id': '1',\n      'author': 'bot',\n      'text': 'ciao',\n      'sentAt': DateTime.now().toIso8601String(),\n    }));\n\n    await Future\u003Cvoid>.delayed(Duration.zero);\n\n    expect(received, hasLength(1));\n    expect(received.first.text, 'ciao');\n\n    await sub.cancel();\n    await service.dispose();\n  });\n}","Il test passa con `flutter test`: il messaggio di tipo `pong` viene ignorato e solo il `ChatMessage` valido raggiunge lo stream tipizzato.",[],1792316895722]