diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6c621f0..654dcc7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -64,6 +64,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart
index 133aecc..7f4956f 100644
--- a/lib/core/links/deep_link_service.dart
+++ b/lib/core/links/deep_link_service.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:app_links/app_links.dart';
import '../../backend/api.dart';
+import '../../frontend/debug/log_export.dart';
import '../../frontend/widgets/max_link_handler.dart';
import '../../main.dart';
import 'desktop_url_scheme.dart';
@@ -16,6 +17,8 @@ class DeepLinkService {
StreamSubscription? _sub;
StreamSubscription? _stateSub;
String? _pending;
+ bool _pendingLogExport = false;
+ Timer? _logExportRetry;
bool _ready = false;
bool _started = false;
@@ -42,6 +45,11 @@ class DeepLinkService {
}
void _onUri(Uri uri) {
+ if (_isLogExportLink(uri)) {
+ _pendingLogExport = true;
+ _flushPending();
+ return;
+ }
final url = _normalize(uri);
if (url == null) return;
_pending = url;
@@ -49,16 +57,46 @@ class DeepLinkService {
}
void _flushPending() {
- final pending = _pending;
- if (pending == null || !_ready) return;
- if (api.state != SessionState.online) return;
final context = KometApp.navigatorKey.currentContext;
- if (context == null) return;
+ if (_pendingLogExport) {
+ if (context == null) {
+ _logExportRetry ??= Timer(const Duration(milliseconds: 300), () {
+ _logExportRetry = null;
+ _flushPending();
+ });
+ } else {
+ _pendingLogExport = false;
+ exportDebugLog(context);
+ }
+ }
+
+ if (!_ready || context == null) return;
+ final pending = _pending;
+ if (pending == null || api.state != SessionState.online) return;
_pending = null;
tryHandleMaxLink(context, pending);
}
+ bool _isLogExportLink(Uri uri) {
+ final scheme = uri.scheme.toLowerCase();
+ final host = uri.host.toLowerCase();
+ final segments = [
+ if (scheme == 'komet' && host.isNotEmpty) host,
+ ...uri.pathSegments,
+ ].where((s) => s.isNotEmpty).toList();
+
+ if (scheme == 'komet') {
+ return segments.length == 1 && segments.first == 'export-logs';
+ }
+ if (scheme == 'https' || scheme == 'http') {
+ return (host == 'komet.pw' || host == 'www.komet.pw') &&
+ segments.length == 1 &&
+ segments.first == 'export-logs';
+ }
+ return false;
+ }
+
String? _normalize(Uri uri) {
final scheme = uri.scheme.toLowerCase();
@@ -82,6 +120,8 @@ class DeepLinkService {
}
void dispose() {
+ _logExportRetry?.cancel();
+ _logExportRetry = null;
_sub?.cancel();
_sub = null;
_stateSub?.cancel();
diff --git a/lib/core/utils/debug_session_log.dart b/lib/core/utils/debug_session_log.dart
index 02cb02f..75eda80 100644
--- a/lib/core/utils/debug_session_log.dart
+++ b/lib/core/utils/debug_session_log.dart
@@ -5,8 +5,16 @@ import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../protocol/opcode_map.dart';
+import 'format.dart';
import 'log_redact.dart';
+class DebugExportFile {
+ final String name;
+ final String content;
+
+ DebugExportFile(this.name, this.content);
+}
+
class _LogEntry {
final int opcode;
final int seq;
@@ -99,7 +107,8 @@ class DebugSessionLog {
DebugSessionLog._();
static final DebugSessionLog instance = DebugSessionLog._();
- static const int _maxSessions = 3;
+ static const Duration _retention = Duration(hours: 24);
+ static const int _maxStoredSessions = 30;
static const int _maxEntriesPerSession = 2000;
static const int _maxLogLinesPerSession = 5000;
static const Duration _flushDebounce = Duration(seconds: 3);
@@ -225,9 +234,17 @@ class DebugSessionLog {
Future _rotate() async {
final files = await _sessionFiles();
- const keep = _maxSessions - 1;
- if (files.length <= keep) return;
- for (final file in files.take(files.length - keep)) {
+ final cutoff = DateTime.now().subtract(_retention).millisecondsSinceEpoch;
+ final stale = [];
+ final fresh = [];
+ for (final file in files) {
+ (_startMillis(file) < cutoff ? stale : fresh).add(file);
+ }
+ const keep = _maxStoredSessions - 1;
+ if (fresh.length > keep) {
+ stale.addAll(fresh.take(fresh.length - keep));
+ }
+ for (final file in stale) {
try {
await file.delete();
} catch (_) {}
@@ -255,7 +272,8 @@ class DebugSessionLog {
return int.tryParse(digits) ?? 0;
}
- Future buildExport({String? endpoint}) async {
+ Future?> buildExportFiles({String? endpoint}) async {
+ final cutoff = DateTime.now().subtract(_retention);
final sessions = <_SessionData>[];
final dir = _dir;
if (dir != null) {
@@ -263,7 +281,10 @@ class DebugSessionLog {
if (_currentFile != null && file.path == _currentFile!.path) continue;
try {
final decoded = jsonDecode(await file.readAsString());
- if (decoded is Map) sessions.add(_SessionData.fromJson(decoded));
+ if (decoded is Map) {
+ final session = _SessionData.fromJson(decoded);
+ if (!session.startedAt.isBefore(cutoff)) sessions.add(session);
+ }
} catch (_) {}
}
}
@@ -277,56 +298,66 @@ class DebugSessionLog {
),
);
sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt));
- final lastN = sessions.length > _maxSessions
- ? sessions.sublist(sessions.length - _maxSessions)
- : sessions;
- final totalEntries = lastN.fold(0, (sum, s) => sum + s.entries.length);
- final totalLogs = lastN.fold(0, (sum, s) => sum + s.logLines.length);
+ final totalEntries = sessions.fold(
+ 0,
+ (sum, s) => sum + s.entries.length,
+ );
+ final totalLogs = sessions.fold(
+ 0,
+ (sum, s) => sum + s.logLines.length,
+ );
if (totalEntries == 0 && totalLogs == 0) return null;
+ final info = StringBuffer();
+ info.writeln('Komet — отладочный лог');
+ if (endpoint != null) info.writeln('Сервер: $endpoint');
+ info.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
+ info.writeln('Период: последние ${_retention.inHours} часа');
+ info.writeln('Заходов в приложение: ${sessions.length}');
+ info.writeln('Всего запросов: $totalEntries');
+ info.writeln('Всего строк лога: $totalLogs');
+ info.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
+
+ final files = [DebugExportFile('info.txt', '$info')];
+ for (var s = 0; s < sessions.length; s++) {
+ final session = sessions[s];
+ final name =
+ 'session_${(s + 1).toString().padLeft(2, '0')}_'
+ '${formatFileStamp(session.startedAt)}.txt';
+ files.add(DebugExportFile(name, _buildSessionText(s + 1, session)));
+ }
+ return files;
+ }
+
+ String _buildSessionText(int index, _SessionData session) {
final buffer = StringBuffer();
- buffer.writeln('Komet — отладочный лог');
- if (endpoint != null) buffer.writeln('Сервер: $endpoint');
- buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
- buffer.writeln('Заходов в приложение: ${lastN.length}');
- buffer.writeln('Всего запросов: $totalEntries');
- buffer.writeln('Всего строк лога: $totalLogs');
- buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
+ buffer.writeln('==================================================');
+ buffer.writeln('ЗАХОД #$index — ${session.startedAt.toIso8601String()}');
+ buffer.writeln(
+ 'запросов: ${session.entries.length}'
+ '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
+ ' · строк лога: ${session.logLines.length}'
+ '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
+ );
+ buffer.writeln('==================================================');
buffer.writeln();
- for (var s = 0; s < lastN.length; s++) {
- final session = lastN[s];
- buffer.writeln('==================================================');
- buffer.writeln(
- 'ЗАХОД #${s + 1} — ${session.startedAt.toIso8601String()}',
- );
- buffer.writeln(
- 'запросов: ${session.entries.length}'
- '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
- ' · строк лога: ${session.logLines.length}'
- '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
- );
- buffer.writeln('==================================================');
- buffer.writeln();
-
- buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----');
- if (session.logLines.isEmpty) {
- buffer.writeln('(пусто)');
- } else {
- for (final line in session.logLines) {
- buffer.writeln(line);
- }
+ buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----');
+ if (session.logLines.isEmpty) {
+ buffer.writeln('(пусто)');
+ } else {
+ for (final line in session.logLines) {
+ buffer.writeln(line);
}
- buffer.writeln();
+ }
+ buffer.writeln();
- buffer.writeln('----- ЗАПРОСЫ -----');
- if (session.entries.isEmpty) {
- buffer.writeln('(пусто)');
- buffer.writeln();
- } else {
- for (var i = 0; i < session.entries.length; i++) {
- _writeEntry(buffer, i + 1, session.entries[i]);
- }
+ buffer.writeln('----- ЗАПРОСЫ -----');
+ if (session.entries.isEmpty) {
+ buffer.writeln('(пусто)');
+ } else {
+ for (var i = 0; i < session.entries.length; i++) {
+ _writeEntry(buffer, i + 1, session.entries[i]);
}
}
return buffer.toString();
diff --git a/lib/frontend/debug/log_export.dart b/lib/frontend/debug/log_export.dart
new file mode 100644
index 0000000..4b6d1b2
--- /dev/null
+++ b/lib/frontend/debug/log_export.dart
@@ -0,0 +1,48 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:archive/archive.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter/widgets.dart';
+
+import '../../core/transport/traffic_monitor.dart';
+import '../../core/utils/debug_session_log.dart';
+import '../../core/utils/format.dart';
+import '../widgets/custom_notification.dart';
+
+Future exportDebugLog(BuildContext context) async {
+ final exportFiles = await DebugSessionLog.instance.buildExportFiles(
+ endpoint: TrafficMonitor.instance.activeEndpoint,
+ );
+ if (exportFiles == null) {
+ if (context.mounted) showCustomNotification(context, 'Лог пуст');
+ return;
+ }
+ final archive = Archive();
+ for (final file in exportFiles) {
+ final data = utf8.encode(file.content);
+ archive.addFile(ArchiveFile(file.name, data.length, data));
+ }
+ final bytes = ZipEncoder().encodeBytes(archive);
+ final fileName = 'komet_debug_${formatFileStamp(DateTime.now())}.zip';
+ final isMobile = Platform.isAndroid || Platform.isIOS;
+ try {
+ final path = await FilePicker.platform.saveFile(
+ dialogTitle: 'Сохранить отладочный лог',
+ fileName: fileName,
+ type: FileType.any,
+ bytes: isMobile ? bytes : null,
+ );
+ if (path == null) return;
+ if (!isMobile) {
+ await File(path).writeAsBytes(bytes);
+ }
+ if (context.mounted) {
+ showCustomNotification(context, 'Лог сохранён: $path');
+ }
+ } catch (e) {
+ if (context.mounted) {
+ showCustomNotification(context, 'Не удалось сохранить лог: $e');
+ }
+ }
+}
diff --git a/lib/frontend/debug/quick_actions_section.dart b/lib/frontend/debug/quick_actions_section.dart
index 0df3867..f5c3ae2 100644
--- a/lib/frontend/debug/quick_actions_section.dart
+++ b/lib/frontend/debug/quick_actions_section.dart
@@ -49,7 +49,7 @@ class DebugQuickActionsSection extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
- 'Все логи и запросы за последние 3 захода в приложение',
+ 'Zip-архив: логи и запросы за последние 24 часа, каждый заход отдельным файлом',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart
index 81fa819..4d47bd3 100644
--- a/lib/frontend/screens/profile/debug_menu_screen.dart
+++ b/lib/frontend/screens/profile/debug_menu_screen.dart
@@ -1,8 +1,3 @@
-import 'dart:convert';
-import 'dart:io';
-
-import 'package:file_picker/file_picker.dart';
-import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart';
@@ -10,8 +5,6 @@ import '../../../core/calls/call_controller.dart';
import '../../../core/config/app_media_cache.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
-import '../../../core/transport/traffic_monitor.dart';
-import '../../../core/utils/debug_session_log.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/media_cache.dart';
@@ -20,6 +13,7 @@ import '../../debug/cache_section.dart';
import '../../debug/feature_toggles_section.dart';
import '../../debug/header_section.dart';
import '../../debug/id_search_section.dart';
+import '../../debug/log_export.dart';
import '../../debug/network_section.dart';
import '../../debug/previews_section.dart';
import '../../debug/quick_actions_section.dart';
@@ -68,38 +62,6 @@ class _DebugMenuScreenState extends State {
if (mounted) setState(() => _cacheSize = size);
}
- Future _exportDebugLog() async {
- final content = await DebugSessionLog.instance.buildExport(
- endpoint: TrafficMonitor.instance.activeEndpoint,
- );
- if (content == null) {
- if (mounted) showCustomNotification(context, 'Лог пуст');
- return;
- }
- final bytes = Uint8List.fromList(utf8.encode(content));
- final fileName = 'komet_debug_${formatFileStamp(DateTime.now())}.txt';
- final isMobile = Platform.isAndroid || Platform.isIOS;
- try {
- final path = await FilePicker.platform.saveFile(
- dialogTitle: 'Сохранить отладочный лог',
- fileName: fileName,
- type: FileType.any,
- bytes: isMobile ? bytes : null,
- );
- if (path == null) return;
- if (!isMobile) {
- await File(path).writeAsBytes(bytes);
- }
- if (mounted) {
- showCustomNotification(context, 'Лог сохранён: $path');
- }
- } catch (e) {
- if (mounted) {
- showCustomNotification(context, 'Не удалось сохранить лог: $e');
- }
- }
- }
-
Future _clearCache() async {
if (_clearingCache) return;
setState(() => _clearingCache = true);
@@ -255,7 +217,9 @@ class _DebugMenuScreenState extends State {
),
),
SliverToBoxAdapter(
- child: DebugQuickActionsSection(onExportLog: _exportDebugLog),
+ child: DebugQuickActionsSection(
+ onExportLog: () => exportDebugLog(context),
+ ),
),
SliverToBoxAdapter(child: DebugNetworkSection(appState: appState)),
const SliverToBoxAdapter(child: DebugFeatureTogglesSection()),
diff --git a/pubspec.lock b/pubspec.lock
index 57a1c9b..66872a1 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -50,7 +50,7 @@ packages:
source: hosted
version: "1.0.4"
archive:
- dependency: transitive
+ dependency: "direct main"
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
diff --git a/pubspec.yaml b/pubspec.yaml
index 5e59c9f..f4cc93e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -49,6 +49,7 @@ dependencies:
flutter_timezone: ^5.0.1
timezone: ^0.11.0
file_picker: ^8.0.0
+ archive: ^4.0.9
geolocator: ^13.0.0
photo_manager: ^3.0.0
image: ^4.3.0