Merge pull request #50 from KometTeam/feature/FullStack

Добавили просмотр аватарок и стабилизировать авторизацию/подключение
This commit is contained in:
klockky
2026-07-05 23:14:48 +03:00
committed by GitHub
20 changed files with 995 additions and 165 deletions
+162 -70
View File
@@ -70,10 +70,16 @@ class Api {
StreamSubscription<SocketState>? _socketStateSubscription;
Timer? _pingTimer;
Timer? _reconnectTimer;
Timer? _connectWatchdog;
int _connectGen = 0;
int _reconnectAttempts = 0;
bool _autoReconnect = false;
int _sessionEpoch = 0;
static const Duration _connectWatchdogTimeout = Duration(seconds: 75);
static const Duration _shouldArmTimeout = Duration(seconds: 5);
static const Duration _endpointTimeout = Duration(seconds: 5);
int get sessionEpoch => _sessionEpoch;
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
@@ -83,87 +89,168 @@ class Api {
/// Подключается к серверу, шлёт хэндшейк, запускает пинг.
Future<void> connect() async {
if (_sessionState != SessionState.disconnected) return;
// Ставим автоматический реконнект и статус подключения
_autoReconnect = true;
_setSessionState(SessionState.connecting);
_dataSubscription = _connection.dataStream.listen(_onDataReceived);
_socketStateSubscription = _connection.stateStream.listen((socketState) {
if (socketState == SocketState.disconnected &&
_sessionState != SessionState.disconnected) {
_onDisconnected();
}
});
final bypassArmed = await VpnBypassService.instance.shouldArm();
if (!bypassArmed) _bypassActive = false;
final useBypass = _bypassActive && bypassArmed;
// Попытку через VPN ограничиваем по времени, чтобы быстро понять,
// что туннель не пропускает, и переключиться на обход.
final attemptTimeout = bypassArmed && !useBypass
? const Duration(seconds: 8)
: null;
try {
final endpoint = await ServerConfig.loadEndpoint();
await _connection.connect(
endpoint.host,
endpoint.port,
bypassVpn: useBypass,
timeout: attemptTimeout,
);
} catch (e) {
await _handleConnectFailure(
e,
phase: 'Не удалось подключиться',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'подключение не удалось',
);
if (_sessionState != SessionState.disconnected) {
logger.i('connect пропущен: состояние ${_sessionState.name}');
return;
}
_setSessionState(SessionState.connected);
_reconnectAttempts = 0;
_autoReconnect = true;
final gen = ++_connectGen;
_setSessionState(SessionState.connecting);
logger.i('connect: старт (поколение $gen)');
_armConnectWatchdog(gen);
try {
final response = await sendHandshake();
if (response.isOk) {
_callsSeed = response.payload['callsSeed'] as int?;
_registrationCountries = _parseRegistrationCountries(response.payload);
_sessionState = SessionState.online;
_sessionEpoch++;
_startPinging();
logger.i('Сессия онлайн, хэндшейк ок');
if (_onReconnectCallback != null) {
try {
await _onReconnectCallback!();
} catch (e) {
logger.w('Авто-логин при хэндшейке не удался: $e');
}
_dataSubscription = _connection.dataStream.listen(_onDataReceived);
_socketStateSubscription = _connection.stateStream.listen((socketState) {
if (socketState == SocketState.disconnected &&
_sessionState != SessionState.disconnected) {
_onDisconnected();
}
if (_sessionState == SessionState.online) {
_stateController.add(SessionState.online);
_handshakeSuccessController.add(
response.payload['device_name'] as String? ?? 'Unknown',
});
bool bypassArmed;
try {
bypassArmed = await VpnBypassService.instance.shouldArm().timeout(
_shouldArmTimeout,
);
} catch (e) {
logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN');
bypassArmed = false;
}
if (gen != _connectGen) return;
if (!bypassArmed) _bypassActive = false;
final useBypass = _bypassActive && bypassArmed;
final attemptTimeout = bypassArmed && !useBypass
? const Duration(seconds: 8)
: null;
({String host, int port}) endpoint;
try {
endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout);
} catch (e) {
logger.w('connect: loadEndpoint завис/упал ($e) — дефолтный endpoint');
endpoint = (
host: ServerConfig.defaultHost,
port: ServerConfig.defaultPort,
);
}
if (gen != _connectGen) return;
logger.i(
'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass',
);
try {
await _connection.connect(
endpoint.host,
endpoint.port,
bypassVpn: useBypass,
timeout: attemptTimeout,
);
} catch (e) {
if (gen != _connectGen) return;
await _handleConnectFailure(
e,
phase: 'Не удалось подключиться',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'подключение не удалось',
);
return;
}
if (gen != _connectGen) return;
_setSessionState(SessionState.connected);
_reconnectAttempts = 0;
try {
logger.i('connect: сокет готов, отправляю хэндшейк');
final response = await sendHandshake();
if (gen != _connectGen) return;
if (response.isOk) {
_callsSeed = response.payload['callsSeed'] as int?;
_registrationCountries = _parseRegistrationCountries(
response.payload,
);
_sessionState = SessionState.online;
_sessionEpoch++;
_cancelConnectWatchdog();
_startPinging();
logger.i('Сессия онлайн, хэндшейк ок');
if (_onReconnectCallback != null) {
try {
await _onReconnectCallback!();
} catch (e) {
logger.w('Авто-логин при хэндшейке не удался: $e');
}
}
if (_sessionState == SessionState.online) {
_stateController.add(SessionState.online);
_handshakeSuccessController.add(
response.payload['device_name'] as String? ?? 'Unknown',
);
}
} else {
logger.e('Хэндшейк отклонён: ${response.payload}');
await _handleConnectFailure(
StateError('хэндшейк отклонён сервером'),
phase: 'Хэндшейк отклонён',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк отклонён',
disconnectSocket: true,
);
}
} else {
logger.e('Хэндшейк отклонён: ${response.payload}');
} catch (e) {
if (gen != _connectGen) return;
await _handleConnectFailure(
e,
phase: 'Ошибка хэндшейка',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк не прошёл',
disconnectSocket: true,
);
}
} catch (e) {
await _handleConnectFailure(
e,
phase: 'Ошибка хэндшейка',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк не прошёл',
disconnectSocket: true,
);
} catch (e, st) {
logger.e('connect: непредвиденная ошибка: $e\n$st');
if (gen == _connectGen) await _resetStuckConnect(gen);
}
}
void _armConnectWatchdog(int gen) {
_connectWatchdog?.cancel();
_connectWatchdog = Timer(_connectWatchdogTimeout, () {
if (gen != _connectGen) return;
if (_sessionState == SessionState.online ||
_sessionState == SessionState.disconnected) {
return;
}
logger.e(
'connect: watchdog ${_connectWatchdogTimeout.inSeconds}с — застряли в '
'${_sessionState.name}, принудительный сброс',
);
unawaited(_resetStuckConnect(gen));
});
}
void _cancelConnectWatchdog() {
_connectWatchdog?.cancel();
_connectWatchdog = null;
}
Future<void> _resetStuckConnect(int gen) async {
if (gen != _connectGen) return;
_connectGen++;
_cancelConnectWatchdog();
_cleanup();
try {
await _connection.disconnect();
} catch (_) {}
_setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect();
}
Future<void> _handleConnectFailure(
Object error, {
required String phase,
@@ -173,6 +260,7 @@ class Api {
bool disconnectSocket = false,
}) async {
logger.e('$phase: $error');
_cancelConnectWatchdog();
if (_sessionState != SessionState.disconnected) {
_cleanup();
if (disconnectSocket) await _connection.disconnect();
@@ -193,6 +281,7 @@ class Api {
Future<void> disconnect() async {
_autoReconnect = false;
_bypassActive = false;
_connectGen++;
_reconnectTimer?.cancel();
_cleanup();
await _connection.disconnect();
@@ -441,6 +530,7 @@ class Api {
}
void _onDisconnected() {
_connectGen++;
_cleanup();
_setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect();
@@ -463,6 +553,7 @@ class Api {
}
Future<void> _forceReconnect() async {
_connectGen++;
_cleanup();
await _connection.disconnect();
_reconnectAttempts = 0;
@@ -472,6 +563,7 @@ class Api {
}
void _cleanup() {
_cancelConnectWatchdog();
_pingTimer?.cancel();
_dataSubscription?.cancel();
_socketStateSubscription?.cancel();
+43 -17
View File
@@ -228,7 +228,9 @@ class AccountModule {
throw Exception('completeRegistration: отсутствует id аккаунта');
}
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
final profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true);
await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId);
@@ -269,16 +271,15 @@ class AccountModule {
final dataMap = data.cast<dynamic, dynamic>();
if (resolvedAccountId == null) {
final profileMap = dataMap['profile'];
if (profileMap is Map) {
final contact = profileMap['contact'];
if (contact is Map) {
resolvedAccountId = contact['id'] as int?;
}
}
resolvedAccountId = extractAccountId(dataMap);
if (resolvedAccountId == null) {
logger.e(
'login: accountId не найден в ответе; '
'${describeResponseShape(dataMap)}',
);
throw Exception('login: не удалось определить accountId из ответа');
}
logger.i('login: accountId=$resolvedAccountId определён из ответа');
await TokenStorage.saveToken(authToken, resolvedAccountId);
await TokenStorage.setActiveAccount(resolvedAccountId);
await SpoofingService.commitPendingSpoof(resolvedAccountId);
@@ -447,8 +448,25 @@ class AccountModule {
throw Exception('checkPassword: отсутствует токен в ответе');
}
final accountId = extractAccountId(data);
if (accountId == null) {
throw Exception('checkPassword: отсутствует accountId в ответе');
}
final profileMap = data['profile'];
if (profileMap is Map) {
final profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true);
}
await TokenStorage.saveToken(loginToken, accountId);
await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId);
logger.i('2FA пройдена, получен login-токен');
return TwoFactorResult(loginToken: loginToken);
return TwoFactorResult(loginToken: loginToken, accountId: accountId);
}
Map<dynamic, dynamic> buildLoginPayload(
@@ -501,16 +519,24 @@ class AccountModule {
await TokenStorage.saveToken(updatedToken, accountId);
}
ProfileData profile;
final profileMap = data['profile'];
if (profileMap is! Map) {
throw Exception('login: отсутствует profile в ответе');
if (profileMap is Map) {
final contact = profileMap['contact'];
if (contact is! Map) {
throw Exception('login: отсутствует profile.contact в ответе');
}
profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true);
} else {
final cachedProfile = await AppDatabase.loadProfile(accountId);
if (cachedProfile == null) {
throw Exception('login: отсутствует profile в ответе');
}
profile = cachedProfile;
}
final contact = profileMap['contact'];
if (contact is! Map) {
throw Exception('login: отсутствует profile.contact в ответе');
}
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
await AppDatabase.saveProfile(profile, isActive: true);
await AppDatabase.setActiveAccount(profile.id);
await _saveSyncState(data, serverTime, profile.id);
@@ -2,6 +2,70 @@ import 'dart:convert';
import '../../../core/storage/app_database.dart';
int? _coerceAccountId(dynamic value) {
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) return int.tryParse(value.trim());
return null;
}
int? extractAccountId(dynamic response) {
if (response is! Map) return null;
final profile = response['profile'];
if (profile is Map) {
final contact = profile['contact'];
if (contact is Map) {
final cid =
_coerceAccountId(contact['id']) ??
_coerceAccountId(contact['contactId']) ??
_coerceAccountId(contact['accountId']);
if (cid != null) return cid;
}
final pid =
_coerceAccountId(profile['id']) ??
_coerceAccountId(profile['accountId']);
if (pid != null) return pid;
}
final contact = response['contact'];
if (contact is Map) {
final cid =
_coerceAccountId(contact['id']) ??
_coerceAccountId(contact['contactId']);
if (cid != null) return cid;
}
final account = response['account'];
if (account is Map) {
final aid =
_coerceAccountId(account['id']) ??
_coerceAccountId(account['accountId']);
if (aid != null) return aid;
}
return _coerceAccountId(response['accountId']) ??
_coerceAccountId(response['account_id']);
}
String describeResponseShape(dynamic response) {
if (response is! Map) return 'не-Map (${response.runtimeType})';
final sb = StringBuffer('keys=${response.keys.toList()}');
final profile = response['profile'];
if (profile is Map) {
sb.write(' profile.keys=${profile.keys.toList()}');
final contact = profile['contact'];
if (contact is Map) {
sb.write(' contact.keys=${contact.keys.toList()}');
} else {
sb.write(' profile.contact=${contact.runtimeType}');
}
} else {
sb.write(' profile=${profile.runtimeType}');
}
return sb.toString();
}
class PrivacyConfig {
final String searchByPhone;
final String incomingCall;
@@ -295,13 +359,7 @@ class VerifyCodeResult {
String? get challengeHint => passwordChallenge?['hint'] as String?;
int? get accountId {
final profileData = payload['profile'];
if (profileData is! Map) return null;
final contact = profileData['contact'];
if (contact is! Map) return null;
return contact['id'] as int?;
}
int? get accountId => extractAccountId(payload);
String? _nestedToken(String key) {
final attrs = payload['tokenAttrs'];
@@ -314,8 +372,9 @@ class VerifyCodeResult {
class TwoFactorResult {
final String loginToken;
final int accountId;
const TwoFactorResult({required this.loginToken});
const TwoFactorResult({required this.loginToken, required this.accountId});
}
class LoginSyncParams {
@@ -18,8 +18,8 @@ class ProfileModule extends AccountApiBase {
if (profile == null) throw Exception('No profile in response');
final contact = profile['contact'] as Map?;
if (contact == null) throw Exception('No contact in response');
final newProfile = ProfileData.fromServerMap(
contact.cast<dynamic, dynamic>(),
final newProfile = ProfileData.fromServerProfile(
profile.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(newProfile, isActive: true);
return newProfile;
@@ -88,7 +88,7 @@ class ProfileModule extends AccountApiBase {
final contact = profile['contact'];
if (contact is! Map) return;
completer.complete(
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()),
ProfileData.fromServerProfile(profile.cast<dynamic, dynamic>()),
);
});
final timer = Timer(const Duration(seconds: 15), () {
+29
View File
@@ -62,6 +62,15 @@ class PhoneLookupResult {
const PhoneLookupResult({required this.id, this.name, this.avatarUrl});
}
class ContactPhotos {
final List<String> urls;
final int total;
const ContactPhotos({required this.urls, required this.total});
static const empty = ContactPhotos(urls: [], total: 0);
}
class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
@@ -194,6 +203,26 @@ class ContactsModule {
}
}
static Future<ContactPhotos> fetchPhotos(
Api api,
int contactId, {
int from = 0,
int count = 25,
}) async {
final map = await api.sendRequestMap(Opcode.contactPhotos, {
'contactId': contactId,
'from': from,
'count': count,
});
if (map == null) return ContactPhotos.empty;
final rawUrls = map['urls'];
final urls = rawUrls is List
? rawUrls.whereType<String>().toList()
: <String>[];
final total = map['total'] is int ? map['total'] as int : urls.length;
return ContactPhotos(urls: urls, total: total);
}
static Future<List<CachedContact>> getContacts(int accountId) async {
final rows = await AppDatabase.loadContacts(accountId);
return rows.map(CachedContact.fromDbRow).toList();
+37 -8
View File
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import '../storage/token_storage.dart';
import '../utils/logger.dart';
enum ProxyType { none, socks5, httpConnect }
@@ -35,26 +38,52 @@ abstract class ProxyConfig {
static const String _prefUsername = 'proxy_username';
static const String _prefPassword = 'proxy_password';
static const Duration _secureReadTimeout = Duration(seconds: 5);
static Future<String?> _readSecureSafe(String key) async {
try {
return await TokenStorage.readSecure(key).timeout(_secureReadTimeout);
} catch (e) {
logger.w('ProxyConfig: чтение secure "$key" не удалось/зависло: $e');
return null;
}
}
static Future<void> _migrateLegacySecure(String key, String value) async {
try {
await TokenStorage.writeSecure(key, value).timeout(_secureReadTimeout);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(key);
} catch (e) {
logger.w('ProxyConfig: миграция legacy "$key" не удалась: $e');
}
}
static Future<ProxySettings> load() async {
final prefs = await SharedPreferences.getInstance();
final typeIndex = prefs.getInt(_prefType) ?? 0;
final host = prefs.getString(_prefHost) ?? '';
final port = prefs.getInt(_prefPort) ?? 1080;
var username = await TokenStorage.readSecure(_prefUsername);
var password = await TokenStorage.readSecure(_prefPassword);
if (ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)] ==
ProxyType.none ||
host.isEmpty) {
return ProxySettings(
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
host: host,
port: port,
);
}
var username = await _readSecureSafe(_prefUsername);
var password = await _readSecureSafe(_prefPassword);
final legacyUsername = prefs.getString(_prefUsername);
final legacyPassword = prefs.getString(_prefPassword);
if (username == null && legacyUsername != null) {
username = legacyUsername;
await TokenStorage.writeSecure(_prefUsername, legacyUsername);
unawaited(_migrateLegacySecure(_prefUsername, legacyUsername));
}
if (password == null && legacyPassword != null) {
password = legacyPassword;
await TokenStorage.writeSecure(_prefPassword, legacyPassword);
}
if (legacyUsername != null || legacyPassword != null) {
await prefs.remove(_prefUsername);
await prefs.remove(_prefPassword);
unawaited(_migrateLegacySecure(_prefPassword, legacyPassword));
}
return ProxySettings(
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
+26 -8
View File
@@ -36,7 +36,21 @@ class ProfileData {
this.profileOptions,
});
factory ProfileData.fromServerMap(Map<dynamic, dynamic> contact) {
factory ProfileData.fromServerProfile(Map<dynamic, dynamic> profile) {
final contact = profile['contact'];
if (contact is! Map) {
throw const FormatException('No contact in profile');
}
return ProfileData.fromServerMap(
contact.cast<dynamic, dynamic>(),
profileOptions: _parseProfileOptions(profile['profileOptions']),
);
}
factory ProfileData.fromServerMap(
Map<dynamic, dynamic> contact, {
List<int>? profileOptions,
}) {
final names = contact['names'];
String firstName = '';
String? lastName;
@@ -52,12 +66,6 @@ class ProfileData {
lastName = name['lastName'] as String?;
}
final profileOptionsRaw = contact['profileOptions'];
List<int>? profileOptions;
if (profileOptionsRaw is List) {
profileOptions = profileOptionsRaw.map((e) => e as int).toList();
}
return ProfileData(
id: contact['id'] as int,
firstName: firstName,
@@ -69,10 +77,20 @@ class ProfileData {
country: (contact['country'] as String?) ?? '',
accountStatus: (contact['accountStatus'] as int?) ?? 0,
updateTime: (contact['updateTime'] as int?) ?? 0,
profileOptions: profileOptions,
profileOptions:
profileOptions ?? _parseProfileOptions(contact['profileOptions']),
);
}
static List<int>? _parseProfileOptions(dynamic raw) {
if (raw is! List) return null;
final options = raw
.map((e) => e is int ? e : int.tryParse(e.toString()))
.whereType<int>()
.toList();
return options.isEmpty ? null : options;
}
factory ProfileData.fromDbRow(Map<String, dynamic> row) {
final profileOptionsStr = row['profile_options'] as String?;
List<int>? profileOptions;
+38 -10
View File
@@ -15,6 +15,8 @@ enum SocketState { disconnected, connecting, connected }
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
class Connection {
static const Duration _defaultConnectTimeout = Duration(seconds: 15);
static const Duration _proxyLoadTimeout = Duration(seconds: 8);
static const Duration _vpnCallTimeout = Duration(seconds: 5);
SecureSocket? _socket;
StreamSubscription<Uint8List>? _subscription;
@@ -40,20 +42,41 @@ class Connection {
bool bypassVpn = false,
Duration? timeout,
}) async {
if (_state != SocketState.disconnected) return;
if (_state != SocketState.disconnected) {
logger.w('Connection.connect пропущен: state=$_state (уже $_state)');
return;
}
_setState(SocketState.connecting);
try {
final proxySettings = await ProxyConfig.load();
// Решение «обходить VPN или нет» принимает вызывающий (Api):
// первая попытка идёт через VPN, при её провале — мимо туннеля.
if (bypassVpn) {
await VpnBypassService.instance.bind();
} else {
await VpnBypassService.instance.restoreDefault();
logger.i('Connection: загрузка прокси-конфига');
ProxySettings proxySettings;
try {
proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout);
} catch (e) {
logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси');
proxySettings = const ProxySettings();
}
logger.i(
'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}',
);
try {
if (bypassVpn) {
await VpnBypassService.instance.bind().timeout(_vpnCallTimeout);
} else {
await VpnBypassService.instance
.restoreDefault()
.timeout(_vpnCallTimeout);
}
} catch (e) {
logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю');
}
logger.i(
'Connection: открываю сокет $host:$port '
'(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})',
);
final socket = await _openSecureSocket(
host,
port,
@@ -109,7 +132,9 @@ class Connection {
socket = await connector.connect(host, port).timeout(connectTimeout);
logger.i('Подключено через прокси ${proxySettings.type.name}');
} else {
logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)');
socket = await Socket.connect(host, port, timeout: connectTimeout);
logger.i('Connection: TCP установлен, начинаю TLS');
}
final allowInsecure = await TlsConfig.isInsecureAllowed();
if (allowInsecure) {
@@ -121,8 +146,11 @@ class Connection {
? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true)
: SecureSocket.secure(socket, host: host);
try {
return await secured.timeout(connectTimeout);
final result = await secured.timeout(connectTimeout);
logger.i('Connection: TLS-handshake завершён');
return result;
} on TimeoutException {
logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с');
socket.destroy();
rethrow;
}
+58 -12
View File
@@ -57,11 +57,15 @@ class _SessionData {
final DateTime startedAt;
final List<_LogEntry> entries;
final bool truncated;
final List<String> logLines;
final bool logsTruncated;
_SessionData({
required this.startedAt,
required this.entries,
this.truncated = false,
this.logLines = const [],
this.logsTruncated = false,
});
static _SessionData fromJson(Map data) {
@@ -72,37 +76,43 @@ class _SessionData {
if (e is Map) entries.add(_LogEntry.fromJson(e));
}
}
final rawLogs = data['logs'];
final logLines = <String>[];
if (rawLogs is List) {
for (final l in rawLogs) {
logLines.add(l.toString());
}
}
return _SessionData(
startedAt:
DateTime.tryParse(data['startedAt']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
entries: entries,
truncated: data['truncated'] == true,
logLines: logLines,
logsTruncated: data['logsTruncated'] == true,
);
}
}
/// Персистентный лог запросов для кнопки «Отладочный лог».
///
/// Хранит ВСЕ запросы (с payload) за последние [_maxSessions] заходов в
/// приложение: каждый запуск — отдельная сессия-файл, старые ротируются.
/// Пинги идут мимо [Api.sendRequest], поэтому в лог не попадают.
///
/// Payload сохраняется уже отредактированным — токен скрыт целиком, от номера
/// остаются первые 3 символа, остальное видно. Поэтому секреты не лежат на диске.
class DebugSessionLog {
DebugSessionLog._();
static final DebugSessionLog instance = DebugSessionLog._();
static const int _maxSessions = 3;
static const int _maxEntriesPerSession = 2000;
static const int _maxLogLinesPerSession = 5000;
static const Duration _flushDebounce = Duration(seconds: 3);
static final RegExp _ansiEscape = RegExp(r'\x1B\[[0-9;]*m');
Directory? _dir;
File? _currentFile;
DateTime? _currentStart;
final List<_LogEntry> _entries = [];
final List<String> _logLines = [];
bool _truncated = false;
bool _logsTruncated = false;
bool _initialized = false;
bool _dirty = false;
Timer? _flushTimer;
@@ -120,12 +130,23 @@ class DebugSessionLog {
_currentFile = File(
'${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json',
);
if (_dirty) _scheduleFlush();
} catch (_) {
_dir = null;
_currentFile = null;
}
}
void recordLogLine(String line) {
final clean = line.replaceAll(_ansiEscape, '');
_logLines.add(clean);
if (_logLines.length > _maxLogLinesPerSession) {
_logLines.removeRange(0, _logLines.length - _maxLogLinesPerSession);
_logsTruncated = true;
}
_scheduleFlush();
}
void recordRequest(int opcode, int seq, dynamic payload) {
_entries.add(
_LogEntry(
@@ -189,6 +210,8 @@ class DebugSessionLog {
'startedAt': _currentStart?.toIso8601String(),
'truncated': _truncated,
'entries': _entries.map((e) => e.toJson()).toList(),
'logsTruncated': _logsTruncated,
'logs': List.of(_logLines),
});
await file.writeAsString(data);
} catch (_) {}
@@ -249,6 +272,8 @@ class DebugSessionLog {
startedAt: _currentStart ?? DateTime.now(),
entries: List.of(_entries),
truncated: _truncated,
logLines: List.of(_logLines),
logsTruncated: _logsTruncated,
),
);
sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt));
@@ -256,7 +281,8 @@ class DebugSessionLog {
? sessions.sublist(sessions.length - _maxSessions)
: sessions;
final totalEntries = lastN.fold<int>(0, (sum, s) => sum + s.entries.length);
if (totalEntries == 0) return null;
final totalLogs = lastN.fold<int>(0, (sum, s) => sum + s.logLines.length);
if (totalEntries == 0 && totalLogs == 0) return null;
final buffer = StringBuffer();
buffer.writeln('Komet — отладочный лог');
@@ -264,6 +290,7 @@ class DebugSessionLog {
buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
buffer.writeln('Заходов в приложение: ${lastN.length}');
buffer.writeln('Всего запросов: $totalEntries');
buffer.writeln('Всего строк лога: $totalLogs');
buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
buffer.writeln();
@@ -275,12 +302,31 @@ class DebugSessionLog {
);
buffer.writeln(
'запросов: ${session.entries.length}'
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}',
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
' · строк лога: ${session.logLines.length}'
'${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
);
buffer.writeln('==================================================');
buffer.writeln();
for (var i = 0; i < session.entries.length; i++) {
_writeEntry(buffer, i + 1, session.entries[i]);
buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----');
if (session.logLines.isEmpty) {
buffer.writeln('(пусто)');
} else {
for (final line in session.logLines) {
buffer.writeln(line);
}
}
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]);
}
}
}
return buffer.toString();
+12 -1
View File
@@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';
import 'debug_session_log.dart';
Level _minimumLogLevel() {
const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: '');
switch (raw.toLowerCase()) {
@@ -41,9 +43,18 @@ final logger = Logger(
filter: _logFilter(),
level: _minimumLogLevel(),
printer: KometLogPrinter(),
output: ConsoleOutput(),
output: MultiOutput([ConsoleOutput(), DebugSessionLogOutput()]),
);
class DebugSessionLogOutput extends LogOutput {
@override
void output(OutputEvent event) {
for (final line in event.lines) {
DebugSessionLog.instance.recordLogLine(line);
}
}
}
int _importanceSortKey(Level level) {
final v = level.value;
if (v >= 5999) {
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'media_cache.dart';
class MediaSaveResult {
final bool ok;
final bool toGallery;
final String? location;
final String? error;
const MediaSaveResult({
required this.ok,
this.toGallery = false,
this.location,
this.error,
});
}
Future<MediaSaveResult> saveImageFromUrl(String url) async {
if (url.isEmpty) {
return const MediaSaveResult(ok: false, error: 'нет ссылки');
}
try {
final cacheName = 'avatar_${url.hashCode & 0x7fffffff}.jpg';
final file = await MediaCache.getOrDownload(cacheName, url);
if (file == null) {
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
}
final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg';
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
final state = await PhotoManager.requestPermissionExtend();
if (!state.isAuth && !state.hasAccess) {
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
}
final bytes = await file.readAsBytes();
await PhotoManager.editor.saveImage(bytes, filename: saveName);
return const MediaSaveResult(ok: true, toGallery: true);
}
final dir = await _targetDirectory();
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
await file.copy(target.path);
return MediaSaveResult(ok: true, location: target.path);
} catch (e) {
return MediaSaveResult(ok: false, error: e.toString());
}
}
Future<Directory> _targetDirectory() async {
try {
final downloads = await getDownloadsDirectory();
if (downloads != null) return downloads;
} catch (_) {}
return getApplicationDocumentsDirectory();
}
@@ -49,7 +49,7 @@ class DebugQuickActionsSection extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
'Все запросы за последние 3 захода в приложение',
'Все логи и запросы за последние 3 захода в приложение',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
@@ -67,7 +67,10 @@ class _Password2FAScreenState extends State<Password2FAScreen>
if (!mounted) return;
final loginResult = await accountModule.login(token: result.loginToken);
final loginResult = await accountModule.login(
accountId: result.accountId,
token: result.loginToken,
);
if (!mounted) return;
@@ -10,6 +10,7 @@ import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/chat_info.dart';
import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
@@ -1130,12 +1131,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
}
Widget _avatar() {
return KometAvatar(
final avatar = KometAvatar(
name: widget.name,
imageUrl: widget.imageUrl,
size: 96,
fontSize: 36,
);
final peerId = widget.chatType == 'DIALOG' ? _otherId : null;
if (peerId == null || widget.imageUrl.isEmpty) return avatar;
return GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: peerId,
name: widget.name,
currentAvatarUrl: widget.imageUrl,
),
child: avatar,
);
}
Widget _buildShimmer(ColorScheme cs) {
@@ -7,6 +7,7 @@ import '../../../core/storage/token_storage.dart';
import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
@@ -146,11 +147,19 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
KometAvatar(
name: _displayName(),
imageUrl: _avatarUrl(),
size: 96,
fontSize: 36,
GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: widget.contactId,
name: _displayName(),
currentAvatarUrl: _avatarUrl(),
),
child: KometAvatar(
name: _displayName(),
imageUrl: _avatarUrl(),
size: 96,
fontSize: 36,
),
),
const SizedBox(height: 14),
_buildNameRow(cs),
@@ -206,7 +215,11 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
label: l10n.contactProfileActionChat,
onTap: _openChat,
),
(icon: Symbols.notifications, label: l10n.contactProfileActionSound, onTap: null),
(
icon: Symbols.notifications,
label: l10n.contactProfileActionSound,
onTap: null,
),
if (!_isBot)
(icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null),
];
@@ -250,17 +263,23 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
final phoneStr = formatPhone(c.raw['phone']);
if (phoneStr != null) {
rows.add(_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr));
rows.add(
_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr),
);
}
final country = c.raw['country'] as String?;
if (country != null && country.isNotEmpty) {
rows.add(_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country));
rows.add(
_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country),
);
}
final genderStr = formatGender(c.raw['gender']);
if (genderStr != null) {
rows.add(_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr));
rows.add(
_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr),
);
}
final regTime = c.raw['registrationTime'] as int?;
@@ -73,7 +73,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
endpoint: TrafficMonitor.instance.activeEndpoint,
);
if (content == null) {
if (mounted) showCustomNotification(context, 'Нет запросов для лога');
if (mounted) showCustomNotification(context, 'Лог пуст');
return;
}
final bytes = Uint8List.fromList(utf8.encode(content));
+23 -14
View File
@@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/info_action_sheet.dart';
import '../../widgets/komet_avatar.dart';
@@ -557,21 +558,29 @@ class _SettingsTabState extends State<SettingsTab> {
],
),
const SizedBox(height: 8),
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
child: KometAvatar(
GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: _profile?.id ?? 0,
name: name,
imageUrl: _profile?.baseUrl,
size: 88,
fontSize: 32,
currentAvatarUrl: _profile?.baseUrl,
),
child: Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
child: KometAvatar(
name: name,
imageUrl: _profile?.baseUrl,
size: 88,
fontSize: 32,
),
),
),
const SizedBox(height: 8),
@@ -0,0 +1,350 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/contacts.dart';
import '../../core/utils/media_saver.dart';
import '../../main.dart';
import 'custom_notification.dart';
class AvatarHistoryScreen extends StatefulWidget {
final int contactId;
final String? name;
final String? currentAvatarUrl;
const AvatarHistoryScreen({
super.key,
required this.contactId,
this.name,
this.currentAvatarUrl,
});
static Future<void> open(
BuildContext context, {
required int contactId,
String? name,
String? currentAvatarUrl,
}) {
final url = currentAvatarUrl;
if (url == null || url.isEmpty) return Future.value();
return Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => AvatarHistoryScreen(
contactId: contactId,
name: name,
currentAvatarUrl: url,
),
),
);
}
@override
State<AvatarHistoryScreen> createState() => _AvatarHistoryScreenState();
}
class _AvatarHistoryScreenState extends State<AvatarHistoryScreen> {
static const int _pageSize = 50;
static const int _maxDots = 10;
final PageController _pageController = PageController();
String? _current;
final List<String> _history = [];
List<String> _pages = const [];
int _historyTotal = 0;
int _index = 0;
bool _loading = true;
bool _loadingMore = false;
bool _saving = false;
@override
void initState() {
super.initState();
final current = widget.currentAvatarUrl;
_current = (current != null && current.isNotEmpty) ? current : null;
_rebuildPages();
_load();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _rebuildPages() {
_pages = _current == null ? List.of(_history) : [_current!, ..._history];
}
void _addHistory(List<String> urls) {
for (final url in urls) {
if (url == _current || _history.contains(url)) continue;
_history.add(url);
}
}
Future<void> _load() async {
final photos = await ContactsModule.fetchPhotos(
api,
widget.contactId,
count: _pageSize,
);
if (!mounted) return;
setState(() {
_addHistory(photos.urls);
_historyTotal = photos.total;
_rebuildPages();
_loading = false;
});
}
Future<void> _loadMore() async {
if (_loadingMore || _history.length >= _historyTotal) return;
_loadingMore = true;
final photos = await ContactsModule.fetchPhotos(
api,
widget.contactId,
from: _history.length,
count: _pageSize,
);
if (!mounted) {
_loadingMore = false;
return;
}
setState(() {
_addHistory(photos.urls);
if (photos.total > _historyTotal) _historyTotal = photos.total;
_rebuildPages();
});
_loadingMore = false;
}
void _onPageChanged(int index) {
setState(() => _index = index);
if (index >= _pages.length - 2) _loadMore();
}
void _prev() {
if (_index <= 0) return;
_pageController.previousPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
void _next() {
if (_index >= _pages.length - 1) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
Future<void> _save() async {
if (_saving || _index >= _pages.length) return;
setState(() => _saving = true);
final result = await saveImageFromUrl(_pages[_index]);
if (!mounted) return;
setState(() => _saving = false);
final message = result.ok
? (result.toGallery
? 'Сохранено в галерею'
: 'Сохранено: ${result.location}')
: 'Не удалось сохранить: ${result.error}';
showCustomNotification(context, message);
}
int get _count {
final total = _historyTotal + (_current != null ? 1 : 0);
return total > _pages.length ? total : _pages.length;
}
@override
Widget build(BuildContext context) {
final topPad = MediaQuery.of(context).padding.top;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(child: _buildBody()),
if (_pages.length > 1 && _index > 0)
_navButton(
alignLeft: true,
icon: Symbols.chevron_left,
onTap: _prev,
),
if (_pages.length > 1 && _index < _pages.length - 1)
_navButton(
alignLeft: false,
icon: Symbols.chevron_right,
onTap: _next,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: topPad + 76,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.55),
Colors.transparent,
],
),
),
),
),
Positioned(
top: topPad + 4,
left: 4,
right: 4,
child: Row(
children: [
IconButton(
icon: const Icon(Symbols.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(child: _buildCounter()),
IconButton(
icon: _saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Colors.white,
),
)
: const Icon(Symbols.download, color: Colors.white),
onPressed: _pages.isEmpty || _saving ? null : _save,
),
],
),
),
if (_pages.length > 1 && _pages.length <= _maxDots)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 18,
left: 0,
right: 0,
child: _buildDots(),
),
],
),
);
}
Widget _buildCounter() {
final hasName = widget.name != null && widget.name!.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_pages.length > 1)
Text(
'${_index + 1} из $_count',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
)
else if (hasName)
Text(
widget.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
if (_pages.length > 1 && hasName)
Text(
widget.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
);
}
Widget _buildBody() {
if (_pages.isEmpty) {
return Center(
child: _loading
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'Нет фотографий',
style: TextStyle(color: Colors.white54, fontSize: 15),
),
);
}
return PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _pages.length,
itemBuilder: (context, i) => Center(
child: CachedNetworkImage(
imageUrl: _pages[i],
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
errorWidget: (_, _, _) =>
const Icon(Symbols.broken_image, color: Colors.white54, size: 64),
),
),
);
}
Widget _navButton({
required bool alignLeft,
required IconData icon,
required VoidCallback onTap,
}) {
return Positioned(
top: 0,
bottom: 0,
left: alignLeft ? 8 : null,
right: alignLeft ? null : 8,
child: Center(
child: Material(
color: Colors.black.withValues(alpha: 0.35),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(icon, color: Colors.white, size: 30),
),
),
),
),
);
}
Widget _buildDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < _pages.length; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: i == _index ? 8 : 6,
height: i == _index ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _index ? Colors.white : Colors.white38,
),
),
],
);
}
}
+39
View File
@@ -105,8 +105,47 @@ Future<Locale> _loadInitialLocale() async {
return const Locale('ru');
}
void _installLogCapture() {
final previousDebugPrint = debugPrint;
debugPrint = (String? message, {int? wrapWidth}) {
if (message != null) {
final t = DateTime.now();
final stamp =
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}';
DebugSessionLog.instance.recordLogLine(' |$stamp P $message');
}
previousDebugPrint(message, wrapWidth: wrapWidth);
};
final previousFlutterOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
DebugSessionLog.instance.recordLogLine(
' | FlutterError: ${details.exceptionAsString()}',
);
if (details.stack != null) {
DebugSessionLog.instance.recordLogLine(details.stack.toString());
}
if (previousFlutterOnError != null) {
previousFlutterOnError(details);
} else {
FlutterError.presentError(details);
}
};
final previousPlatformOnError = ui.PlatformDispatcher.instance.onError;
ui.PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
DebugSessionLog.instance.recordLogLine(' | Uncaught: $error');
DebugSessionLog.instance.recordLogLine(stack.toString());
if (previousPlatformOnError != null) {
return previousPlatformOnError(error, stack);
}
return false;
};
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
_installLogCapture();
VideoPlayerMediaKit.ensureInitialized(
windows: true,
linux: true,
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.5.0+13
version: 0.5.0+14
environment:
sdk: ^3.10.4