import 'dart:convert'; import 'dart:math'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/num.dart'; import '../../l10n/app_localizations.dart'; import '../../network/http/http.dart'; import '../../network/http/websocket.dart'; import '../../utils/platform.dart'; import '../component/app_dialog.dart'; import '../component/json/json_text.dart'; import '../component/json/json_viewer.dart'; import '../component/json/theme.dart'; ///以聊天对话框样式展示websocket消息 class Websocket extends StatelessWidget { final ValueWrap request; final ValueWrap response; const Websocket(this.request, this.response, {super.key}); @override Widget build(BuildContext context) { AppLocalizations localizations = AppLocalizations.of(context)!; var request = this.request.get(); if (request == null) { return const SizedBox(); } List messages = List.from(request.messages); var response = this.response.get(); if (response != null) { messages.addAll(response.messages); } messages.sort((a, b) => a.time.compareTo(b.time)); return ListView.builder( padding: const EdgeInsets.only(bottom: 15), itemCount: messages.length, itemBuilder: (context, index) { var message = messages[index]; var avatar = SelectionContainer.disabled( child: CircleAvatar( backgroundColor: message.isFromClient ? Colors.green : Colors.blue, child: Text(message.isFromClient ? 'C' : 'S', style: const TextStyle(fontSize: 18, color: Colors.white)))); var previewButton = IconButton( tooltip: "Preview", onPressed: () { showDialog(context: context, builder: (context) => _PreviewDialog(bytes: message.payloadData)); }, icon: Icon(Icons.expand_more, color: ColorScheme.of(context).primary), ); return Padding( padding: const EdgeInsets.only(bottom: 5), child: Row( mainAxisAlignment: message.isFromClient ? MainAxisAlignment.start : MainAxisAlignment.end, children: [ if (message.isFromClient) avatar, const SizedBox(width: 8), Flexible( child: Column( crossAxisAlignment: message.isFromClient ? CrossAxisAlignment.start : CrossAxisAlignment.end, children: [ SelectionContainer.disabled( child: Text(message.time.format(), style: const TextStyle(fontSize: 12, color: Colors.grey))), Row(mainAxisSize: MainAxisSize.min, children: [ if (!message.isFromClient) previewButton, Flexible( child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: message.isFromClient ? Colors.green.withOpacity(0.26) : Colors.blue.withOpacity(0.3), borderRadius: BorderRadius.circular(10), ), child: SelectableText( "${message.payloadDataAsString}${message.isBinary ? ' ${getPackage(message.payloadLength)}' : ''}", maxLines: 3, contextMenuBuilder: (context, editableTextState) => contextMenu(context, editableTextState, customItem: ContextMenuButtonItem( label: localizations.download, onPressed: () async { String? path = (await FilePicker.platform .saveFile(fileName: "websocket.txt", bytes: message.payloadData)); if (path != null && context.mounted) { CustomToast.success(localizations.saveSuccess).show(context); } }, type: ContextMenuButtonType.custom, )), )), ), if (message.isFromClient) previewButton, ]) ]), ), const SizedBox(width: 8), if (!message.isFromClient) avatar, ], )); }, ); } } class _PreviewDialog extends StatefulWidget { final List bytes; const _PreviewDialog({required this.bytes}); @override State<_PreviewDialog> createState() => _PreviewDialogState(); } class _PreviewDialogState extends State<_PreviewDialog> { int tabIndex = 0; // 0: HEX, 1: TEXT @override Widget build(BuildContext context) { var tabs = [ if (isJsonText(widget.bytes)) const Tab(text: "JSON Text"), if (isJsonText(widget.bytes)) const Tab(text: "JSON"), const Tab(text: "TEXT"), const Tab(text: "HEX"), ]; return AlertDialog( content: SizedBox( width: min(MediaQuery.of(context).size.width * 0.8, 700), height: min(MediaQuery.of(context).size.height * 0.6, 650), child: DefaultTabController( length: tabs.length, initialIndex: tabIndex, child: Column(mainAxisSize: MainAxisSize.min, children: [ TabBar( tabs: tabs, onTap: (index) { setState(() { tabIndex = index; }); }, ), Expanded( child: TabBarView(children: [ if (isJsonText(widget.bytes)) SingleChildScrollView(padding: const EdgeInsets.all(8.0), child: jsonText()), if (isJsonText(widget.bytes)) SingleChildScrollView(padding: const EdgeInsets.all(8.0), child: jsonView()), // TEXT SingleChildScrollView( padding: const EdgeInsets.all(8), child: SelectableText(safeTextPreview(widget.bytes)), ), // HEX SingleChildScrollView( padding: const EdgeInsets.all(8), child: SelectableText(widget.bytes.map(intToHex).join(" ")), ), ]), ), ]), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(MaterialLocalizations.of(context).closeButtonLabel)) ], ); } Widget jsonText() { String body = utf8.decode(widget.bytes, allowMalformed: true); dynamic jsonData; try { jsonData = json.decode(body); } catch (e) { jsonData = null; } if (jsonData == null) { return SelectableText(safeTextPreview(widget.bytes)); } return JsonText(json: jsonData, indent: Platforms.isDesktop() ? ' ' : ' ', colorTheme: ColorTheme.of(context)); } Widget jsonView() { String body = utf8.decode(widget.bytes, allowMalformed: true); dynamic jsonData; try { jsonData = json.decode(body); } catch (e) { jsonData = null; } if (jsonData == null) { return SelectableText(safeTextPreview(widget.bytes)); } return JsonViewer(json.decode(body), colorTheme: ColorTheme.of(context)); } //判断是否是json格式 bool isJsonText(List bytes) { return bytes.isNotEmpty && (bytes[0] == 0x7B || bytes[0] == 0x5B); } /// Format bytes as hex-dump string: 4 bytes per group (8 hex digits), space between groups, line break every 16 bytes String formatHexDump(List bytes) { final buffer = StringBuffer(); // 每8个字节为一组,用空格分隔,组之间换行 for (int i = 0; i < bytes.length; i++) { // 添加当前十六进制部分 buffer.write(bytes[i].toRadixString(16).padLeft(2, '0')); if ((i + 1) % 2 == 0 && i != bytes.length - 1) { buffer.write(' '); } } return buffer.toString(); } /// Decode bytes to string, non-printable as '.' String safeTextPreview(List bytes) { try { return utf8.decode(bytes); } catch (_) { return bytes.map((b) => b >= 32 && b <= 126 ? String.fromCharCode(b) : '.').join(); } } }