|
|
|
|
@ -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<void> 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<String, String> hints = <String, String>{
|
|
|
|
|
'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<HubConnection> 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: <int>[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: <String, String>{
|
|
|
|
|
"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: <int>[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;
|
|
|
|
|
|