feat(devtools): монитор сокет-трафика в меню разработчика

This commit is contained in:
klockky
2026-06-28 14:05:24 +03:00
parent 662ddb2fbd
commit 574206dc21
9 changed files with 830 additions and 0 deletions
+2
View File
@@ -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' ||
+13
View File
@@ -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
View File
@@ -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)}}',
);
+236
View File
@@ -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';
}
}
+3
View File
@@ -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,
+24
View File
@@ -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:
+1
View File
@@ -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