diff --git a/lib/modules/cx_module/chat/calling/call_provider.dart b/lib/modules/cx_module/chat/calling/call_provider.dart index defbc775..277ac82c 100644 --- a/lib/modules/cx_module/chat/calling/call_provider.dart +++ b/lib/modules/cx_module/chat/calling/call_provider.dart @@ -56,12 +56,46 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { super.dispose(); } + /// See [ChatProvider.buildHubConnection] for why a `:0` port can show up in + /// SignalR websocket errors - it is a `dart:io` artifact (Uri.port == 0 for + /// the `wss` scheme), not a real port. Read the trailing HTTP status instead. Future getHubConnection(String token) async { - HubConnection hub; - HttpConnectionOptions httpOp = HttpConnectionOptions(skipNegotiation: false, logMessageContent: true); - hub = HubConnectionBuilder().withUrl("${URLs.chatHubUrlChat}?access_token=$token", options: httpOp).withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]).build(); + // Encode the JWT properly and also pass it as an Authorization header: + // on mobile signalr_netcore ignores the query token for the websocket + // handshake and relies on `accessTokenFactory`. + final String hubUrl = _urlWithExplicitPort( + Uri.parse(URLs.chatHubUrlChat).replace(queryParameters: { + "access_token": token, + }), + ); + + final HttpConnectionOptions httpOp = HttpConnectionOptions( + skipNegotiation: false, + logMessageContent: true, + accessTokenFactory: () async => token, + requestTimeout: 30000, + ); + + return HubConnectionBuilder() + .withUrl(hubUrl, options: httpOp) + .withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]) + .build(); + } - return hub; + /// Writes the port into the url *textually*. + /// + /// Why this is not just `uri.replace(port: 443)`: `Uri` normalises a port away + /// when it equals the scheme default, so `.replace(port: 443)` on an https uri + /// serialises back to `https://host/...` with no port at all. signalr_netcore + /// only does string surgery on this url (`url.replaceFirst('http', 'ws')`), so + /// the port has to be present in the string to survive into the `wss://` form. + /// Without it `Uri.port` is `0` for `ws`/`wss` (dart:io only knows defaults for + /// http/https), which is exactly what gets echoed back as `https://host:0/...` + /// in WebSocketException messages. + static String _urlWithExplicitPort(Uri uri) { + final int port = uri.hasPort ? uri.port : (uri.scheme == 'https' || uri.scheme == 'wss' ? 443 : 80); + final String query = uri.query.isEmpty ? '' : '?${uri.query}'; + return '${uri.scheme}://${uri.host}:$port${uri.path}$query'; } Future buildHubConnection(String employeeNumber, {HubConnection? hubC}) async { diff --git a/lib/modules/cx_module/chat/chat_provider.dart b/lib/modules/cx_module/chat/chat_provider.dart index fb309e2f..2fbe22e9 100644 --- a/lib/modules/cx_module/chat/chat_provider.dart +++ b/lib/modules/cx_module/chat/chat_provider.dart @@ -306,6 +306,21 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { // } + /// Opens the SignalR connection for [conversationID]. + /// + /// IMPORTANT - about the ":0" port you may see in the logs: + /// `WebSocketException: Connection to 'https://atomsmdev.hmg.com:0/api/DeriChat/hubs/chat?...' + /// was not upgraded to websocket, HTTP status code: 401` + /// + /// The `:0` is NOT coming from our url and it is NOT a real port. `dart:io` + /// only knows default ports for `http`/`https`, so `Uri.parse('wss://host/x').port` + /// evaluates to `0`. `_WebSocketImpl.connect()` then rebuilds the uri with + /// `port: uri.port` (= 0) purely to print it / feed `HttpClient.openUrl`, and + /// `HttpClient` maps port 0 back to the scheme default (443). The socket is + /// therefore opened on 443 as expected. + /// => Ignore the `:0`, the real failure is always the `HTTP status code` at the + /// end of that message (usually 401 = expired/missing chat token). + /// [_withExplicitPort] below suppresses the `:0` so the logs stay readable. Future buildHubConnection(String conversationID) async { try { // Dispose existing connection if any @@ -332,7 +347,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { // chatHubConnection.on("OnDeliveredGroupChatHistoryAsync", onGroupMsgReceived); } catch (e) { if (kDebugMode) { - print('⚠️ Error building SignalR connection: $e'); + print('⚠️ Error building SignalR connection: ${_describeHubError(e)}'); } // Clean up on error await _disposeConnection(); @@ -340,19 +355,85 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { } } + /// Writes the port into the url *textually*. + /// + /// Why this is not just `uri.replace(port: 443)`: `Uri` normalises a port away + /// when it equals the scheme default, so `.replace(port: 443)` on an https uri + /// serialises back to `https://host/...` with no port at all. signalr_netcore + /// only does string surgery on this url (`url.replaceFirst('http', 'ws')`), so + /// the port has to be present in the string to survive into the `wss://` form. + /// Without it `Uri.port` is `0` for `ws`/`wss` (dart:io only knows defaults for + /// http/https), which is exactly what gets echoed back as `https://host:0/...` + /// in WebSocketException messages. + static String _urlWithExplicitPort(Uri uri) { + final int port = uri.hasPort ? uri.port : (uri.scheme == 'https' || uri.scheme == 'wss' ? 443 : 80); + final String query = uri.query.isEmpty ? '' : '?${uri.query}'; + return '${uri.scheme}://${uri.host}:$port${uri.path}$query'; + } + + /// Turns SignalR/websocket failures into something actionable. + /// The `:0` in the raw text is noise - what matters is the HTTP status code. + static String _describeHubError(Object e) { + final String raw = e.toString(); + final Match? status = RegExp(r'HTTP status code:\s*(\d{3})').firstMatch(raw); + if (status == null) return raw; + final String code = status.group(1)!; + const Map hints = { + '401': 'chat token missing/expired -> re-run getUserAutoLoginTokenSilent()', + '403': 'token valid but not allowed to join this conversation', + '404': 'hub path is wrong -> check URLs.chatHubUrlChat', + '502': 'gateway/proxy is not forwarding the websocket upgrade', + '504': 'gateway timeout on the websocket upgrade', + }; + return 'websocket upgrade rejected with HTTP $code' + '${hints[code] == null ? '' : ' (${hints[code]})'}' + ' [ignore any ":0" port in the raw message]\n raw: $raw'; + } + Future getHubConnection() async { if (kDebugMode) { print('🔧 Creating new SignalR hub connection...'); } - HubConnection hub; - HttpConnectionOptions httpOp = HttpConnectionOptions(skipNegotiation: false, logMessageContent: true); - hub = HubConnectionBuilder() - .withUrl("${URLs.chatHubUrlChat}?UserId=${chatLoginResponse!.userId}&source=Desktop&access_token=${chatLoginResponse!.token}", options: httpOp) - .withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]).build(); + final ChatLoginResponse? login = chatLoginResponse; + final String? token = login?.token; + if (token == null || token.isEmpty) { + throw StateError('Cannot open SignalR hub: chat login token is null/empty. Call getUserAutoLoginTokenSilent() first.'); + } + + // NOTE: build the url with Uri so every query value is properly encoded. + // Never hand-concatenate the JWT - unencoded characters silently break the + // negotiate request and the server answers 401 (which surfaces as the + // confusing "https://host:0/... was not upgraded to websocket" error, + // see comment on [buildHubConnection]). + final Uri hubUri = Uri.parse(URLs.chatHubUrlChat).replace(queryParameters: { + "UserId": "${login?.userId}", + "source": "Desktop", + "access_token": token, + }); + final String hubUrl = _urlWithExplicitPort(hubUri); + + // Send the token as a real `Authorization: Bearer` header too. + // signalr_netcore uses `accessTokenFactory` for BOTH the /negotiate call and + // the websocket handshake on mobile (query strings are only used on web), + // so without this the websocket upgrade request is unauthenticated -> 401. + final HttpConnectionOptions httpOp = HttpConnectionOptions( + skipNegotiation: false, + logMessageContent: true, + accessTokenFactory: () async => token, + requestTimeout: 30000, // package default is only 2000ms + ); + + final HubConnection hub = HubConnectionBuilder() + .withUrl(hubUrl, options: httpOp) + .withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]) + .build(); if (kDebugMode) { - print('✅ SignalR hub connection created'); + print('✅ SignalR hub connection created -> ${_urlWithExplicitPort(hubUri.replace(queryParameters: { + ...hubUri.queryParameters, + "access_token": "***", + }))}'); } return hub;