feat(devtools): монитор сокет-трафика в меню разработчика
This commit is contained in:
@@ -13,6 +13,7 @@ import '../core/transport/connection.dart';
|
||||
import '../core/transport/dispatcher.dart';
|
||||
import '../core/transport/receiver.dart';
|
||||
import '../core/transport/sender.dart';
|
||||
import '../core/transport/traffic_monitor.dart';
|
||||
import '../core/transport/vpn_bypass.dart';
|
||||
import '../core/utils/logger.dart';
|
||||
|
||||
@@ -342,6 +343,7 @@ class Api {
|
||||
logger.e('PacketReceiver: ошибка распаковки: $e');
|
||||
continue;
|
||||
}
|
||||
TrafficMonitor.instance.recordIncoming(packet, raw.length);
|
||||
if (packet.isError &&
|
||||
packet.payload is Map &&
|
||||
(packet.payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../config/proxy_config.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'proxy_connector.dart';
|
||||
import 'tls_config.dart';
|
||||
import 'traffic_monitor.dart';
|
||||
import 'vpn_bypass.dart';
|
||||
|
||||
enum SocketState { disconnected, connecting, connected }
|
||||
@@ -64,6 +65,17 @@ class Connection {
|
||||
_setState(SocketState.connected);
|
||||
logger.i('Подключено к $host:$port');
|
||||
|
||||
final route = proxySettings.isEnabled
|
||||
? 'через прокси ${proxySettings.type.name}'
|
||||
: bypassVpn
|
||||
? 'напрямую (обход VPN)'
|
||||
: 'прямое соединение';
|
||||
TrafficMonitor.instance.recordEvent(
|
||||
'Подключено',
|
||||
detail: '$host:$port · TLS · $route',
|
||||
endpoint: '$host:$port',
|
||||
);
|
||||
|
||||
_subscription = _socket!.listen(
|
||||
(data) {
|
||||
if (!_dataController.isClosed) _dataController.add(data);
|
||||
@@ -130,6 +142,7 @@ class Connection {
|
||||
_socket = null;
|
||||
|
||||
if (socket != null) {
|
||||
TrafficMonitor.instance.recordEvent('Соединение закрыто');
|
||||
try {
|
||||
await socket.close();
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import '../protocol/packet.dart';
|
||||
import '../utils/log_redact.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'connection.dart';
|
||||
import 'traffic_monitor.dart';
|
||||
|
||||
class PacketSender {
|
||||
int _seq = 0;
|
||||
@@ -17,6 +18,7 @@ class PacketSender {
|
||||
final seq = _nextSeq();
|
||||
final data = packPacket(opcode, payload, seq: seq);
|
||||
connection.write(data);
|
||||
TrafficMonitor.instance.recordOutgoing(opcode, payload, seq, data.length);
|
||||
logger.i(
|
||||
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../protocol/opcode_map.dart';
|
||||
import '../protocol/packet.dart';
|
||||
|
||||
enum TrafficDirection { outgoing, incoming, event }
|
||||
|
||||
class TrafficEntry {
|
||||
final TrafficDirection direction;
|
||||
final DateTime time;
|
||||
final String label;
|
||||
final int? opcode;
|
||||
final int? seq;
|
||||
final int? cmd;
|
||||
final dynamic payload;
|
||||
final int? byteSize;
|
||||
final String? detail;
|
||||
|
||||
TrafficEntry({
|
||||
required this.direction,
|
||||
required this.time,
|
||||
required this.label,
|
||||
this.opcode,
|
||||
this.seq,
|
||||
this.cmd,
|
||||
this.payload,
|
||||
this.byteSize,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
String get prettyPayload => prettyJson(payload);
|
||||
}
|
||||
|
||||
String prettyJson(dynamic value) {
|
||||
if (value == null) return 'null';
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(_sanitize(value));
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const _redacted = '***';
|
||||
|
||||
const _sensitiveExportFields = {
|
||||
'token',
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
'authtoken',
|
||||
'password',
|
||||
'secret',
|
||||
'phone',
|
||||
'phonenumber',
|
||||
'email',
|
||||
'msisdn',
|
||||
'otp',
|
||||
'smscode',
|
||||
'verifycode',
|
||||
'pin',
|
||||
'qrlink',
|
||||
'webappdata',
|
||||
'deviceid',
|
||||
'instanceid',
|
||||
'mt_instanceid',
|
||||
'text',
|
||||
'caption',
|
||||
};
|
||||
|
||||
bool _isSensitiveExportKey(Object? key) {
|
||||
if (key is! String) return false;
|
||||
return _sensitiveExportFields.contains(key.toLowerCase());
|
||||
}
|
||||
|
||||
dynamic _redactForExport(dynamic value) {
|
||||
if (value is Map) {
|
||||
final out = {};
|
||||
value.forEach((k, v) {
|
||||
out[k] = _isSensitiveExportKey(k) ? _redacted : _redactForExport(v);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (value is List) return value.map(_redactForExport).toList();
|
||||
return value;
|
||||
}
|
||||
|
||||
dynamic _sanitize(dynamic value) {
|
||||
if (value is Map) {
|
||||
final out = <String, dynamic>{};
|
||||
value.forEach((k, v) => out[k.toString()] = _sanitize(v));
|
||||
return out;
|
||||
}
|
||||
if (value is Uint8List) return '<bytes: ${value.length}>';
|
||||
if (value is List) return value.map(_sanitize).toList();
|
||||
if (value is num || value is bool || value is String) return value;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/// Перехватчик сокет-трафика для меню разработчика.
|
||||
///
|
||||
/// Захват включается только пока открыт экран монитора ([enabled]),
|
||||
/// поэтому в обычной работе хуки в sender/dispatcher/connection почти
|
||||
/// бесплатны (один ранний выход по флагу).
|
||||
class TrafficMonitor extends ChangeNotifier {
|
||||
TrafficMonitor._();
|
||||
static final TrafficMonitor instance = TrafficMonitor._();
|
||||
|
||||
static const int _maxEntries = 1000;
|
||||
static const String _prefKey = 'dev_traffic_capture';
|
||||
static const bool _defaultEnabled = false;
|
||||
|
||||
final List<TrafficEntry> _entries = [];
|
||||
String? _activeEndpoint;
|
||||
|
||||
final ValueNotifier<bool> captureEnabled = ValueNotifier(_defaultEnabled);
|
||||
|
||||
bool get enabled => captureEnabled.value;
|
||||
|
||||
List<TrafficEntry> get entries => List.unmodifiable(_entries);
|
||||
String? get activeEndpoint => _activeEndpoint;
|
||||
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
captureEnabled.value = prefs.getBool(_prefKey) ?? _defaultEnabled;
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool value) async {
|
||||
if (captureEnabled.value != value) {
|
||||
captureEnabled.value = value;
|
||||
notifyListeners();
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKey, value);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_entries.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Сериализует захваченный трафик для экспорта.
|
||||
/// Чувствительные поля payload (токены, телефоны, коды и т.п.)
|
||||
/// маскируются через [redactForLog] — файлом можно делиться.
|
||||
String buildExport({String? appVersion}) {
|
||||
final data = <String, dynamic>{
|
||||
'tool': 'Komet traffic monitor',
|
||||
'appVersion': ?appVersion,
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'endpoint': _activeEndpoint,
|
||||
'entryCount': _entries.length,
|
||||
'sensitiveDataRedacted': true,
|
||||
'entries': _entries.map(_entryToJson).toList(),
|
||||
};
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _entryToJson(TrafficEntry e) {
|
||||
return <String, dynamic>{
|
||||
'time': e.time.toIso8601String(),
|
||||
'direction': e.direction.name,
|
||||
'label': e.label,
|
||||
if (e.opcode != null) 'opcode': e.opcode,
|
||||
if (e.seq != null) 'seq': e.seq,
|
||||
if (e.cmd != null) 'cmd': e.cmd,
|
||||
if (e.byteSize != null) 'bytes': e.byteSize,
|
||||
if (e.detail != null) 'detail': e.detail,
|
||||
if (e.payload != null) 'payload': _sanitize(_redactForExport(e.payload)),
|
||||
};
|
||||
}
|
||||
|
||||
void recordOutgoing(int opcode, dynamic payload, int seq, int byteSize) {
|
||||
if (!enabled) return;
|
||||
_add(
|
||||
TrafficEntry(
|
||||
direction: TrafficDirection.outgoing,
|
||||
time: DateTime.now(),
|
||||
label: Opcode.name(opcode),
|
||||
opcode: opcode,
|
||||
seq: seq,
|
||||
cmd: CmdType.request,
|
||||
payload: payload,
|
||||
byteSize: byteSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void recordIncoming(Packet packet, int byteSize) {
|
||||
if (!enabled) return;
|
||||
_add(
|
||||
TrafficEntry(
|
||||
direction: TrafficDirection.incoming,
|
||||
time: DateTime.now(),
|
||||
label: Opcode.name(packet.opcode),
|
||||
opcode: packet.opcode,
|
||||
seq: packet.seq,
|
||||
cmd: packet.cmd,
|
||||
payload: packet.payload,
|
||||
byteSize: byteSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void recordEvent(String label, {String? detail, String? endpoint}) {
|
||||
if (endpoint != null) _activeEndpoint = endpoint;
|
||||
if (!enabled) return;
|
||||
_add(
|
||||
TrafficEntry(
|
||||
direction: TrafficDirection.event,
|
||||
time: DateTime.now(),
|
||||
label: label,
|
||||
detail: detail,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _add(TrafficEntry entry) {
|
||||
_entries.add(entry);
|
||||
if (_entries.length > _maxEntries) {
|
||||
_entries.removeRange(0, _entries.length - _maxEntries);
|
||||
}
|
||||
_scheduleNotify();
|
||||
}
|
||||
|
||||
bool _notifyScheduled = false;
|
||||
|
||||
void _scheduleNotify() {
|
||||
if (_notifyScheduled) return;
|
||||
_notifyScheduled = true;
|
||||
Future.microtask(() {
|
||||
_notifyScheduled = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import '../calls/call_screen.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
import 'traffic_monitor_screen.dart';
|
||||
|
||||
class DebugMenuScreen extends StatefulWidget {
|
||||
const DebugMenuScreen({super.key});
|
||||
@@ -496,6 +497,71 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const TrafficMonitorScreen(),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.lan,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Монитор трафика',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Реалтайм: домены, опкоды и payload внутри '
|
||||
'сокет-соединения',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.chevron_right,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/transport/traffic_monitor.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class TrafficMonitorScreen extends StatefulWidget {
|
||||
const TrafficMonitorScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TrafficMonitorScreen> createState() => _TrafficMonitorScreenState();
|
||||
}
|
||||
|
||||
class _TrafficMonitorScreenState extends State<TrafficMonitorScreen> {
|
||||
final _monitor = TrafficMonitor.instance;
|
||||
final _scrollController = ScrollController();
|
||||
final Set<TrafficEntry> _expanded = Set.identity();
|
||||
bool _stickToBottom = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final pos = _scrollController.position;
|
||||
_stickToBottom = pos.pixels >= pos.maxScrollExtent - 80;
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final pos = _scrollController.position;
|
||||
if (pos.userScrollDirection != ScrollDirection.idle) return;
|
||||
if (pos.pixels >= pos.maxScrollExtent) return;
|
||||
_scrollController.jumpTo(pos.maxScrollExtent);
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_expanded.clear();
|
||||
_monitor.clear();
|
||||
}
|
||||
|
||||
Future<void> _share() async {
|
||||
if (_monitor.entries.isEmpty) return;
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
try {
|
||||
final json = _monitor.buildExport();
|
||||
final dir = await getTemporaryDirectory();
|
||||
final stamp = _fileStamp(DateTime.now());
|
||||
final file = File('${dir.path}/komet_traffic_$stamp.json');
|
||||
await file.writeAsString(json);
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path, mimeType: 'application/json')],
|
||||
subject: 'Komet traffic capture',
|
||||
sharePositionOrigin: box == null
|
||||
? null
|
||||
: box.localToGlobal(Offset.zero) & box.size,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось поделиться: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _fileStamp(DateTime t) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${t.year}${two(t.month)}${two(t.day)}_'
|
||||
'${two(t.hour)}${two(t.minute)}${two(t.second)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(cs),
|
||||
_controlBar(cs),
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
animation: _monitor,
|
||||
builder: (context, _) {
|
||||
final entries = _monitor.entries;
|
||||
if (_stickToBottom && _expanded.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _scrollToBottom(),
|
||||
);
|
||||
}
|
||||
if (entries.isEmpty) return _emptyState(cs);
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, i) {
|
||||
final entry = entries[i];
|
||||
return _TrafficRow(
|
||||
key: ValueKey(entry),
|
||||
entry: entry,
|
||||
expanded: _expanded.contains(entry),
|
||||
onToggle: () => setState(() {
|
||||
if (!_expanded.remove(entry)) _expanded.add(entry);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(ColorScheme cs) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.arrow_back,
|
||||
color: cs.onSurface,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Монитор трафика',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _monitor,
|
||||
builder: (context, _) {
|
||||
final empty = _monitor.entries.isEmpty;
|
||||
final activeColor = empty
|
||||
? cs.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: cs.onSurface;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Поделиться (без чувствительных данных)',
|
||||
icon: Icon(
|
||||
Symbols.ios_share,
|
||||
color: activeColor,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: empty ? null : _share,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Очистить',
|
||||
icon: Icon(
|
||||
Symbols.delete_sweep,
|
||||
color: activeColor,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: empty ? null : _clear,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _controlBar(ColorScheme cs) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 4, 12, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 12, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _monitor,
|
||||
builder: (context, _) {
|
||||
final on = _monitor.enabled;
|
||||
final endpoint = _monitor.activeEndpoint;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: on ? const Color(0xFF34C759) : cs.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
endpoint ?? 'Нет соединения',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
on
|
||||
? 'Захват включён · ${_monitor.entries.length}'
|
||||
: 'Захват остановлен · ${_monitor.entries.length}',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: on,
|
||||
onChanged: (v) => _monitor.setEnabled(v),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyState(ColorScheme cs) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.cell_tower, size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_monitor.enabled ? 'Ожидание трафика…' : 'Захват выключен',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrafficRow extends StatelessWidget {
|
||||
final TrafficEntry entry;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _TrafficRow({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final e = entry;
|
||||
final accent = _accentColor(cs, e.direction);
|
||||
final hasPayload = e.payload != null;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border(left: BorderSide(color: accent, width: 3)),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: hasPayload ? onToggle : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 9, 12, 9),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(e.time),
|
||||
style: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(_directionIcon(e.direction), color: accent, size: 15),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (e.byteSize != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formatBytes(e.byteSize!),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasPayload)
|
||||
Icon(
|
||||
expanded ? Symbols.expand_less : Symbols.expand_more,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_meta(e).isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 49),
|
||||
child: Text(
|
||||
_meta(e),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (expanded && hasPayload) ...[
|
||||
const SizedBox(height: 8),
|
||||
_payloadBox(context, cs, e),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _meta(TrafficEntry e) {
|
||||
if (e.detail != null) return e.detail!;
|
||||
final parts = <String>[];
|
||||
if (e.opcode != null) parts.add('op ${e.opcode}');
|
||||
if (e.seq != null) parts.add('seq ${e.seq}');
|
||||
if (e.cmd != null) parts.add(_cmdLabel(e.cmd!, e.direction));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
Widget _payloadBox(BuildContext context, ColorScheme cs, TrafficEntry e) {
|
||||
final text = e.prettyPayload;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 6, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Скопировать',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(
|
||||
Symbols.content_copy,
|
||||
size: 16,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (context.mounted) {
|
||||
showCustomNotification(context, 'Payload скопирован');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _accentColor(ColorScheme cs, TrafficDirection d) {
|
||||
switch (d) {
|
||||
case TrafficDirection.outgoing:
|
||||
return cs.primary;
|
||||
case TrafficDirection.incoming:
|
||||
return cs.tertiary;
|
||||
case TrafficDirection.event:
|
||||
return cs.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _directionIcon(TrafficDirection d) {
|
||||
switch (d) {
|
||||
case TrafficDirection.outgoing:
|
||||
return Symbols.north_east;
|
||||
case TrafficDirection.incoming:
|
||||
return Symbols.south_west;
|
||||
case TrafficDirection.event:
|
||||
return Symbols.lan;
|
||||
}
|
||||
}
|
||||
|
||||
String _cmdLabel(int cmd, TrafficDirection direction) {
|
||||
switch (cmd) {
|
||||
case CmdType.ok:
|
||||
return 'OK';
|
||||
case CmdType.notFound:
|
||||
return 'NOT_FOUND';
|
||||
case CmdType.error:
|
||||
return 'ERROR';
|
||||
default:
|
||||
return direction == TrafficDirection.incoming ? 'PUSH' : 'REQ';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime t) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final ms = t.millisecond.toString().padLeft(3, '0');
|
||||
return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}.$ms';
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import 'frontend/screens/calls/call_screen.dart';
|
||||
import 'core/push/push_service.dart';
|
||||
import 'core/storage/app_database.dart';
|
||||
import 'core/transport/tls_config.dart';
|
||||
import 'core/transport/traffic_monitor.dart';
|
||||
import 'core/transport/vpn_bypass.dart';
|
||||
import 'core/storage/token_storage.dart';
|
||||
import 'core/utils/haptics.dart';
|
||||
@@ -128,6 +129,7 @@ void main() async {
|
||||
final cacheLimitFuture = AppMediaCacheLimit.load();
|
||||
final digitalIdNativeFuture = AppDigitalIdNative.load();
|
||||
final showExtraInfoFuture = AppShowExtraInfo.load();
|
||||
final trafficCaptureFuture = TrafficMonitor.instance.load();
|
||||
|
||||
final packageInfo = await packageInfoFuture;
|
||||
isOnemeFlavor = packageInfo.packageName == 'ru.oneme.app';
|
||||
@@ -173,6 +175,7 @@ void main() async {
|
||||
AppMediaCacheLimit.current.value = await cacheLimitFuture;
|
||||
AppDigitalIdNative.current.value = await digitalIdNativeFuture;
|
||||
AppShowExtraInfo.current.value = await showExtraInfoFuture;
|
||||
await trafficCaptureFuture;
|
||||
runApp(
|
||||
KometApp(
|
||||
initialLocale: initialLocale,
|
||||
|
||||
@@ -885,6 +885,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1205,6 +1213,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.4"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -60,6 +60,7 @@ dependencies:
|
||||
mobile_scanner: ^7.2.0
|
||||
cached_network_image: ^3.4.1
|
||||
path_provider: ^2.1.4
|
||||
share_plus: ^10.1.4
|
||||
open_filex: ^4.5.0
|
||||
url_launcher: ^6.3.1
|
||||
app_links: ^7.0.0
|
||||
|
||||
Reference in New Issue
Block a user