Merge pull request #50 from KometTeam/feature/FullStack
Добавили просмотр аватарок и стабилизировать авторизацию/подключение
This commit is contained in:
+162
-70
@@ -70,10 +70,16 @@ class Api {
|
|||||||
StreamSubscription<SocketState>? _socketStateSubscription;
|
StreamSubscription<SocketState>? _socketStateSubscription;
|
||||||
Timer? _pingTimer;
|
Timer? _pingTimer;
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
|
Timer? _connectWatchdog;
|
||||||
|
int _connectGen = 0;
|
||||||
int _reconnectAttempts = 0;
|
int _reconnectAttempts = 0;
|
||||||
bool _autoReconnect = false;
|
bool _autoReconnect = false;
|
||||||
int _sessionEpoch = 0;
|
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;
|
int get sessionEpoch => _sessionEpoch;
|
||||||
|
|
||||||
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
|
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
|
||||||
@@ -83,87 +89,168 @@ class Api {
|
|||||||
|
|
||||||
/// Подключается к серверу, шлёт хэндшейк, запускает пинг.
|
/// Подключается к серверу, шлёт хэндшейк, запускает пинг.
|
||||||
Future<void> connect() async {
|
Future<void> connect() async {
|
||||||
if (_sessionState != SessionState.disconnected) return;
|
if (_sessionState != SessionState.disconnected) {
|
||||||
// Ставим автоматический реконнект и статус подключения
|
logger.i('connect пропущен: состояние ${_sessionState.name}');
|
||||||
_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: 'подключение не удалось',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_autoReconnect = true;
|
||||||
_setSessionState(SessionState.connected);
|
final gen = ++_connectGen;
|
||||||
_reconnectAttempts = 0;
|
_setSessionState(SessionState.connecting);
|
||||||
|
logger.i('connect: старт (поколение $gen)');
|
||||||
|
_armConnectWatchdog(gen);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await sendHandshake();
|
_dataSubscription = _connection.dataStream.listen(_onDataReceived);
|
||||||
if (response.isOk) {
|
_socketStateSubscription = _connection.stateStream.listen((socketState) {
|
||||||
_callsSeed = response.payload['callsSeed'] as int?;
|
if (socketState == SocketState.disconnected &&
|
||||||
_registrationCountries = _parseRegistrationCountries(response.payload);
|
_sessionState != SessionState.disconnected) {
|
||||||
_sessionState = SessionState.online;
|
_onDisconnected();
|
||||||
_sessionEpoch++;
|
|
||||||
_startPinging();
|
|
||||||
logger.i('Сессия онлайн, хэндшейк ок');
|
|
||||||
if (_onReconnectCallback != null) {
|
|
||||||
try {
|
|
||||||
await _onReconnectCallback!();
|
|
||||||
} catch (e) {
|
|
||||||
logger.w('Авто-логин при хэндшейке не удался: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (_sessionState == SessionState.online) {
|
});
|
||||||
_stateController.add(SessionState.online);
|
|
||||||
_handshakeSuccessController.add(
|
bool bypassArmed;
|
||||||
response.payload['device_name'] as String? ?? 'Unknown',
|
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 {
|
} catch (e) {
|
||||||
logger.e('Хэндшейк отклонён: ${response.payload}');
|
if (gen != _connectGen) return;
|
||||||
|
await _handleConnectFailure(
|
||||||
|
e,
|
||||||
|
phase: 'Ошибка хэндшейка',
|
||||||
|
bypassArmed: bypassArmed,
|
||||||
|
useBypass: useBypass,
|
||||||
|
bypassWhy: 'хэндшейк не прошёл',
|
||||||
|
disconnectSocket: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
await _handleConnectFailure(
|
logger.e('connect: непредвиденная ошибка: $e\n$st');
|
||||||
e,
|
if (gen == _connectGen) await _resetStuckConnect(gen);
|
||||||
phase: 'Ошибка хэндшейка',
|
|
||||||
bypassArmed: bypassArmed,
|
|
||||||
useBypass: useBypass,
|
|
||||||
bypassWhy: 'хэндшейк не прошёл',
|
|
||||||
disconnectSocket: true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Future<void> _handleConnectFailure(
|
||||||
Object error, {
|
Object error, {
|
||||||
required String phase,
|
required String phase,
|
||||||
@@ -173,6 +260,7 @@ class Api {
|
|||||||
bool disconnectSocket = false,
|
bool disconnectSocket = false,
|
||||||
}) async {
|
}) async {
|
||||||
logger.e('$phase: $error');
|
logger.e('$phase: $error');
|
||||||
|
_cancelConnectWatchdog();
|
||||||
if (_sessionState != SessionState.disconnected) {
|
if (_sessionState != SessionState.disconnected) {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
if (disconnectSocket) await _connection.disconnect();
|
if (disconnectSocket) await _connection.disconnect();
|
||||||
@@ -193,6 +281,7 @@ class Api {
|
|||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_autoReconnect = false;
|
_autoReconnect = false;
|
||||||
_bypassActive = false;
|
_bypassActive = false;
|
||||||
|
_connectGen++;
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
_cleanup();
|
_cleanup();
|
||||||
await _connection.disconnect();
|
await _connection.disconnect();
|
||||||
@@ -441,6 +530,7 @@ class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDisconnected() {
|
void _onDisconnected() {
|
||||||
|
_connectGen++;
|
||||||
_cleanup();
|
_cleanup();
|
||||||
_setSessionState(SessionState.disconnected);
|
_setSessionState(SessionState.disconnected);
|
||||||
if (_autoReconnect) _scheduleReconnect();
|
if (_autoReconnect) _scheduleReconnect();
|
||||||
@@ -463,6 +553,7 @@ class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _forceReconnect() async {
|
Future<void> _forceReconnect() async {
|
||||||
|
_connectGen++;
|
||||||
_cleanup();
|
_cleanup();
|
||||||
await _connection.disconnect();
|
await _connection.disconnect();
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
@@ -472,6 +563,7 @@ class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _cleanup() {
|
void _cleanup() {
|
||||||
|
_cancelConnectWatchdog();
|
||||||
_pingTimer?.cancel();
|
_pingTimer?.cancel();
|
||||||
_dataSubscription?.cancel();
|
_dataSubscription?.cancel();
|
||||||
_socketStateSubscription?.cancel();
|
_socketStateSubscription?.cancel();
|
||||||
|
|||||||
@@ -228,7 +228,9 @@ class AccountModule {
|
|||||||
throw Exception('completeRegistration: отсутствует id аккаунта');
|
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 AppDatabase.saveProfile(profile, isActive: true);
|
||||||
await TokenStorage.setActiveAccount(accountId);
|
await TokenStorage.setActiveAccount(accountId);
|
||||||
await SpoofingService.commitPendingSpoof(accountId);
|
await SpoofingService.commitPendingSpoof(accountId);
|
||||||
@@ -269,16 +271,15 @@ class AccountModule {
|
|||||||
final dataMap = data.cast<dynamic, dynamic>();
|
final dataMap = data.cast<dynamic, dynamic>();
|
||||||
|
|
||||||
if (resolvedAccountId == null) {
|
if (resolvedAccountId == null) {
|
||||||
final profileMap = dataMap['profile'];
|
resolvedAccountId = extractAccountId(dataMap);
|
||||||
if (profileMap is Map) {
|
|
||||||
final contact = profileMap['contact'];
|
|
||||||
if (contact is Map) {
|
|
||||||
resolvedAccountId = contact['id'] as int?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (resolvedAccountId == null) {
|
if (resolvedAccountId == null) {
|
||||||
|
logger.e(
|
||||||
|
'login: accountId не найден в ответе; '
|
||||||
|
'${describeResponseShape(dataMap)}',
|
||||||
|
);
|
||||||
throw Exception('login: не удалось определить accountId из ответа');
|
throw Exception('login: не удалось определить accountId из ответа');
|
||||||
}
|
}
|
||||||
|
logger.i('login: accountId=$resolvedAccountId определён из ответа');
|
||||||
await TokenStorage.saveToken(authToken, resolvedAccountId);
|
await TokenStorage.saveToken(authToken, resolvedAccountId);
|
||||||
await TokenStorage.setActiveAccount(resolvedAccountId);
|
await TokenStorage.setActiveAccount(resolvedAccountId);
|
||||||
await SpoofingService.commitPendingSpoof(resolvedAccountId);
|
await SpoofingService.commitPendingSpoof(resolvedAccountId);
|
||||||
@@ -447,8 +448,25 @@ class AccountModule {
|
|||||||
throw Exception('checkPassword: отсутствует токен в ответе');
|
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-токен');
|
logger.i('2FA пройдена, получен login-токен');
|
||||||
return TwoFactorResult(loginToken: loginToken);
|
return TwoFactorResult(loginToken: loginToken, accountId: accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<dynamic, dynamic> buildLoginPayload(
|
Map<dynamic, dynamic> buildLoginPayload(
|
||||||
@@ -501,16 +519,24 @@ class AccountModule {
|
|||||||
await TokenStorage.saveToken(updatedToken, accountId);
|
await TokenStorage.saveToken(updatedToken, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProfileData profile;
|
||||||
final profileMap = data['profile'];
|
final profileMap = data['profile'];
|
||||||
if (profileMap is! Map) {
|
if (profileMap is Map) {
|
||||||
throw Exception('login: отсутствует profile в ответе');
|
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 AppDatabase.setActiveAccount(profile.id);
|
||||||
|
|
||||||
await _saveSyncState(data, serverTime, profile.id);
|
await _saveSyncState(data, serverTime, profile.id);
|
||||||
|
|||||||
@@ -2,6 +2,70 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import '../../../core/storage/app_database.dart';
|
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 {
|
class PrivacyConfig {
|
||||||
final String searchByPhone;
|
final String searchByPhone;
|
||||||
final String incomingCall;
|
final String incomingCall;
|
||||||
@@ -295,13 +359,7 @@ class VerifyCodeResult {
|
|||||||
|
|
||||||
String? get challengeHint => passwordChallenge?['hint'] as String?;
|
String? get challengeHint => passwordChallenge?['hint'] as String?;
|
||||||
|
|
||||||
int? get accountId {
|
int? get accountId => extractAccountId(payload);
|
||||||
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?;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _nestedToken(String key) {
|
String? _nestedToken(String key) {
|
||||||
final attrs = payload['tokenAttrs'];
|
final attrs = payload['tokenAttrs'];
|
||||||
@@ -314,8 +372,9 @@ class VerifyCodeResult {
|
|||||||
|
|
||||||
class TwoFactorResult {
|
class TwoFactorResult {
|
||||||
final String loginToken;
|
final String loginToken;
|
||||||
|
final int accountId;
|
||||||
|
|
||||||
const TwoFactorResult({required this.loginToken});
|
const TwoFactorResult({required this.loginToken, required this.accountId});
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoginSyncParams {
|
class LoginSyncParams {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class ProfileModule extends AccountApiBase {
|
|||||||
if (profile == null) throw Exception('No profile in response');
|
if (profile == null) throw Exception('No profile in response');
|
||||||
final contact = profile['contact'] as Map?;
|
final contact = profile['contact'] as Map?;
|
||||||
if (contact == null) throw Exception('No contact in response');
|
if (contact == null) throw Exception('No contact in response');
|
||||||
final newProfile = ProfileData.fromServerMap(
|
final newProfile = ProfileData.fromServerProfile(
|
||||||
contact.cast<dynamic, dynamic>(),
|
profile.cast<dynamic, dynamic>(),
|
||||||
);
|
);
|
||||||
await AppDatabase.saveProfile(newProfile, isActive: true);
|
await AppDatabase.saveProfile(newProfile, isActive: true);
|
||||||
return newProfile;
|
return newProfile;
|
||||||
@@ -88,7 +88,7 @@ class ProfileModule extends AccountApiBase {
|
|||||||
final contact = profile['contact'];
|
final contact = profile['contact'];
|
||||||
if (contact is! Map) return;
|
if (contact is! Map) return;
|
||||||
completer.complete(
|
completer.complete(
|
||||||
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()),
|
ProfileData.fromServerProfile(profile.cast<dynamic, dynamic>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
final timer = Timer(const Duration(seconds: 15), () {
|
final timer = Timer(const Duration(seconds: 15), () {
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ class PhoneLookupResult {
|
|||||||
const PhoneLookupResult({required this.id, this.name, this.avatarUrl});
|
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 {
|
class ContactsModule {
|
||||||
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
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 {
|
static Future<List<CachedContact>> getContacts(int accountId) async {
|
||||||
final rows = await AppDatabase.loadContacts(accountId);
|
final rows = await AppDatabase.loadContacts(accountId);
|
||||||
return rows.map(CachedContact.fromDbRow).toList();
|
return rows.map(CachedContact.fromDbRow).toList();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../storage/token_storage.dart';
|
import '../storage/token_storage.dart';
|
||||||
|
import '../utils/logger.dart';
|
||||||
|
|
||||||
enum ProxyType { none, socks5, httpConnect }
|
enum ProxyType { none, socks5, httpConnect }
|
||||||
|
|
||||||
@@ -35,26 +38,52 @@ abstract class ProxyConfig {
|
|||||||
static const String _prefUsername = 'proxy_username';
|
static const String _prefUsername = 'proxy_username';
|
||||||
static const String _prefPassword = 'proxy_password';
|
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 {
|
static Future<ProxySettings> load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final typeIndex = prefs.getInt(_prefType) ?? 0;
|
final typeIndex = prefs.getInt(_prefType) ?? 0;
|
||||||
final host = prefs.getString(_prefHost) ?? '';
|
final host = prefs.getString(_prefHost) ?? '';
|
||||||
final port = prefs.getInt(_prefPort) ?? 1080;
|
final port = prefs.getInt(_prefPort) ?? 1080;
|
||||||
var username = await TokenStorage.readSecure(_prefUsername);
|
if (ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)] ==
|
||||||
var password = await TokenStorage.readSecure(_prefPassword);
|
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 legacyUsername = prefs.getString(_prefUsername);
|
||||||
final legacyPassword = prefs.getString(_prefPassword);
|
final legacyPassword = prefs.getString(_prefPassword);
|
||||||
if (username == null && legacyUsername != null) {
|
if (username == null && legacyUsername != null) {
|
||||||
username = legacyUsername;
|
username = legacyUsername;
|
||||||
await TokenStorage.writeSecure(_prefUsername, legacyUsername);
|
unawaited(_migrateLegacySecure(_prefUsername, legacyUsername));
|
||||||
}
|
}
|
||||||
if (password == null && legacyPassword != null) {
|
if (password == null && legacyPassword != null) {
|
||||||
password = legacyPassword;
|
password = legacyPassword;
|
||||||
await TokenStorage.writeSecure(_prefPassword, legacyPassword);
|
unawaited(_migrateLegacySecure(_prefPassword, legacyPassword));
|
||||||
}
|
|
||||||
if (legacyUsername != null || legacyPassword != null) {
|
|
||||||
await prefs.remove(_prefUsername);
|
|
||||||
await prefs.remove(_prefPassword);
|
|
||||||
}
|
}
|
||||||
return ProxySettings(
|
return ProxySettings(
|
||||||
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
|
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
|
||||||
|
|||||||
@@ -36,7 +36,21 @@ class ProfileData {
|
|||||||
this.profileOptions,
|
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'];
|
final names = contact['names'];
|
||||||
String firstName = '';
|
String firstName = '';
|
||||||
String? lastName;
|
String? lastName;
|
||||||
@@ -52,12 +66,6 @@ class ProfileData {
|
|||||||
lastName = name['lastName'] as String?;
|
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(
|
return ProfileData(
|
||||||
id: contact['id'] as int,
|
id: contact['id'] as int,
|
||||||
firstName: firstName,
|
firstName: firstName,
|
||||||
@@ -69,10 +77,20 @@ class ProfileData {
|
|||||||
country: (contact['country'] as String?) ?? '',
|
country: (contact['country'] as String?) ?? '',
|
||||||
accountStatus: (contact['accountStatus'] as int?) ?? 0,
|
accountStatus: (contact['accountStatus'] as int?) ?? 0,
|
||||||
updateTime: (contact['updateTime'] 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) {
|
factory ProfileData.fromDbRow(Map<String, dynamic> row) {
|
||||||
final profileOptionsStr = row['profile_options'] as String?;
|
final profileOptionsStr = row['profile_options'] as String?;
|
||||||
List<int>? profileOptions;
|
List<int>? profileOptions;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ enum SocketState { disconnected, connecting, connected }
|
|||||||
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
||||||
class Connection {
|
class Connection {
|
||||||
static const Duration _defaultConnectTimeout = Duration(seconds: 15);
|
static const Duration _defaultConnectTimeout = Duration(seconds: 15);
|
||||||
|
static const Duration _proxyLoadTimeout = Duration(seconds: 8);
|
||||||
|
static const Duration _vpnCallTimeout = Duration(seconds: 5);
|
||||||
|
|
||||||
SecureSocket? _socket;
|
SecureSocket? _socket;
|
||||||
StreamSubscription<Uint8List>? _subscription;
|
StreamSubscription<Uint8List>? _subscription;
|
||||||
@@ -40,20 +42,41 @@ class Connection {
|
|||||||
bool bypassVpn = false,
|
bool bypassVpn = false,
|
||||||
Duration? timeout,
|
Duration? timeout,
|
||||||
}) async {
|
}) async {
|
||||||
if (_state != SocketState.disconnected) return;
|
if (_state != SocketState.disconnected) {
|
||||||
|
logger.w('Connection.connect пропущен: state=$_state (уже $_state)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
_setState(SocketState.connecting);
|
_setState(SocketState.connecting);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final proxySettings = await ProxyConfig.load();
|
logger.i('Connection: загрузка прокси-конфига');
|
||||||
|
ProxySettings proxySettings;
|
||||||
// Решение «обходить VPN или нет» принимает вызывающий (Api):
|
try {
|
||||||
// первая попытка идёт через VPN, при её провале — мимо туннеля.
|
proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout);
|
||||||
if (bypassVpn) {
|
} catch (e) {
|
||||||
await VpnBypassService.instance.bind();
|
logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси');
|
||||||
} else {
|
proxySettings = const ProxySettings();
|
||||||
await VpnBypassService.instance.restoreDefault();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
final socket = await _openSecureSocket(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
@@ -109,7 +132,9 @@ class Connection {
|
|||||||
socket = await connector.connect(host, port).timeout(connectTimeout);
|
socket = await connector.connect(host, port).timeout(connectTimeout);
|
||||||
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
||||||
} else {
|
} else {
|
||||||
|
logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)');
|
||||||
socket = await Socket.connect(host, port, timeout: connectTimeout);
|
socket = await Socket.connect(host, port, timeout: connectTimeout);
|
||||||
|
logger.i('Connection: TCP установлен, начинаю TLS');
|
||||||
}
|
}
|
||||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||||
if (allowInsecure) {
|
if (allowInsecure) {
|
||||||
@@ -121,8 +146,11 @@ class Connection {
|
|||||||
? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true)
|
? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true)
|
||||||
: SecureSocket.secure(socket, host: host);
|
: SecureSocket.secure(socket, host: host);
|
||||||
try {
|
try {
|
||||||
return await secured.timeout(connectTimeout);
|
final result = await secured.timeout(connectTimeout);
|
||||||
|
logger.i('Connection: TLS-handshake завершён');
|
||||||
|
return result;
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
|
logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с');
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,11 +57,15 @@ class _SessionData {
|
|||||||
final DateTime startedAt;
|
final DateTime startedAt;
|
||||||
final List<_LogEntry> entries;
|
final List<_LogEntry> entries;
|
||||||
final bool truncated;
|
final bool truncated;
|
||||||
|
final List<String> logLines;
|
||||||
|
final bool logsTruncated;
|
||||||
|
|
||||||
_SessionData({
|
_SessionData({
|
||||||
required this.startedAt,
|
required this.startedAt,
|
||||||
required this.entries,
|
required this.entries,
|
||||||
this.truncated = false,
|
this.truncated = false,
|
||||||
|
this.logLines = const [],
|
||||||
|
this.logsTruncated = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
static _SessionData fromJson(Map data) {
|
static _SessionData fromJson(Map data) {
|
||||||
@@ -72,37 +76,43 @@ class _SessionData {
|
|||||||
if (e is Map) entries.add(_LogEntry.fromJson(e));
|
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(
|
return _SessionData(
|
||||||
startedAt:
|
startedAt:
|
||||||
DateTime.tryParse(data['startedAt']?.toString() ?? '') ??
|
DateTime.tryParse(data['startedAt']?.toString() ?? '') ??
|
||||||
DateTime.fromMillisecondsSinceEpoch(0),
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
entries: entries,
|
entries: entries,
|
||||||
truncated: data['truncated'] == true,
|
truncated: data['truncated'] == true,
|
||||||
|
logLines: logLines,
|
||||||
|
logsTruncated: data['logsTruncated'] == true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Персистентный лог запросов для кнопки «Отладочный лог».
|
|
||||||
///
|
|
||||||
/// Хранит ВСЕ запросы (с payload) за последние [_maxSessions] заходов в
|
|
||||||
/// приложение: каждый запуск — отдельная сессия-файл, старые ротируются.
|
|
||||||
/// Пинги идут мимо [Api.sendRequest], поэтому в лог не попадают.
|
|
||||||
///
|
|
||||||
/// Payload сохраняется уже отредактированным — токен скрыт целиком, от номера
|
|
||||||
/// остаются первые 3 символа, остальное видно. Поэтому секреты не лежат на диске.
|
|
||||||
class DebugSessionLog {
|
class DebugSessionLog {
|
||||||
DebugSessionLog._();
|
DebugSessionLog._();
|
||||||
static final DebugSessionLog instance = DebugSessionLog._();
|
static final DebugSessionLog instance = DebugSessionLog._();
|
||||||
|
|
||||||
static const int _maxSessions = 3;
|
static const int _maxSessions = 3;
|
||||||
static const int _maxEntriesPerSession = 2000;
|
static const int _maxEntriesPerSession = 2000;
|
||||||
|
static const int _maxLogLinesPerSession = 5000;
|
||||||
static const Duration _flushDebounce = Duration(seconds: 3);
|
static const Duration _flushDebounce = Duration(seconds: 3);
|
||||||
|
|
||||||
|
static final RegExp _ansiEscape = RegExp(r'\x1B\[[0-9;]*m');
|
||||||
|
|
||||||
Directory? _dir;
|
Directory? _dir;
|
||||||
File? _currentFile;
|
File? _currentFile;
|
||||||
DateTime? _currentStart;
|
DateTime? _currentStart;
|
||||||
final List<_LogEntry> _entries = [];
|
final List<_LogEntry> _entries = [];
|
||||||
|
final List<String> _logLines = [];
|
||||||
bool _truncated = false;
|
bool _truncated = false;
|
||||||
|
bool _logsTruncated = false;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
Timer? _flushTimer;
|
Timer? _flushTimer;
|
||||||
@@ -120,12 +130,23 @@ class DebugSessionLog {
|
|||||||
_currentFile = File(
|
_currentFile = File(
|
||||||
'${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json',
|
'${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json',
|
||||||
);
|
);
|
||||||
|
if (_dirty) _scheduleFlush();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_dir = null;
|
_dir = null;
|
||||||
_currentFile = 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) {
|
void recordRequest(int opcode, int seq, dynamic payload) {
|
||||||
_entries.add(
|
_entries.add(
|
||||||
_LogEntry(
|
_LogEntry(
|
||||||
@@ -189,6 +210,8 @@ class DebugSessionLog {
|
|||||||
'startedAt': _currentStart?.toIso8601String(),
|
'startedAt': _currentStart?.toIso8601String(),
|
||||||
'truncated': _truncated,
|
'truncated': _truncated,
|
||||||
'entries': _entries.map((e) => e.toJson()).toList(),
|
'entries': _entries.map((e) => e.toJson()).toList(),
|
||||||
|
'logsTruncated': _logsTruncated,
|
||||||
|
'logs': List.of(_logLines),
|
||||||
});
|
});
|
||||||
await file.writeAsString(data);
|
await file.writeAsString(data);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -249,6 +272,8 @@ class DebugSessionLog {
|
|||||||
startedAt: _currentStart ?? DateTime.now(),
|
startedAt: _currentStart ?? DateTime.now(),
|
||||||
entries: List.of(_entries),
|
entries: List.of(_entries),
|
||||||
truncated: _truncated,
|
truncated: _truncated,
|
||||||
|
logLines: List.of(_logLines),
|
||||||
|
logsTruncated: _logsTruncated,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt));
|
sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt));
|
||||||
@@ -256,7 +281,8 @@ class DebugSessionLog {
|
|||||||
? sessions.sublist(sessions.length - _maxSessions)
|
? sessions.sublist(sessions.length - _maxSessions)
|
||||||
: sessions;
|
: sessions;
|
||||||
final totalEntries = lastN.fold<int>(0, (sum, s) => sum + s.entries.length);
|
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();
|
final buffer = StringBuffer();
|
||||||
buffer.writeln('Komet — отладочный лог');
|
buffer.writeln('Komet — отладочный лог');
|
||||||
@@ -264,6 +290,7 @@ class DebugSessionLog {
|
|||||||
buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
|
buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
|
||||||
buffer.writeln('Заходов в приложение: ${lastN.length}');
|
buffer.writeln('Заходов в приложение: ${lastN.length}');
|
||||||
buffer.writeln('Всего запросов: $totalEntries');
|
buffer.writeln('Всего запросов: $totalEntries');
|
||||||
|
buffer.writeln('Всего строк лога: $totalLogs');
|
||||||
buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
|
buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
|
||||||
buffer.writeln();
|
buffer.writeln();
|
||||||
|
|
||||||
@@ -275,12 +302,31 @@ class DebugSessionLog {
|
|||||||
);
|
);
|
||||||
buffer.writeln(
|
buffer.writeln(
|
||||||
'запросов: ${session.entries.length}'
|
'запросов: ${session.entries.length}'
|
||||||
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}',
|
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
|
||||||
|
' · строк лога: ${session.logLines.length}'
|
||||||
|
'${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
|
||||||
);
|
);
|
||||||
buffer.writeln('==================================================');
|
buffer.writeln('==================================================');
|
||||||
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();
|
return buffer.toString();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
import 'debug_session_log.dart';
|
||||||
|
|
||||||
Level _minimumLogLevel() {
|
Level _minimumLogLevel() {
|
||||||
const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: '');
|
const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: '');
|
||||||
switch (raw.toLowerCase()) {
|
switch (raw.toLowerCase()) {
|
||||||
@@ -41,9 +43,18 @@ final logger = Logger(
|
|||||||
filter: _logFilter(),
|
filter: _logFilter(),
|
||||||
level: _minimumLogLevel(),
|
level: _minimumLogLevel(),
|
||||||
printer: KometLogPrinter(),
|
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) {
|
int _importanceSortKey(Level level) {
|
||||||
final v = level.value;
|
final v = level.value;
|
||||||
if (v >= 5999) {
|
if (v >= 5999) {
|
||||||
|
|||||||
@@ -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),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Все запросы за последние 3 захода в приложение',
|
'Все логи и запросы за последние 3 захода в приложение',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurfaceVariant,
|
color: cs.onSurfaceVariant,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class _Password2FAScreenState extends State<Password2FAScreen>
|
|||||||
|
|
||||||
if (!mounted) return;
|
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;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../../../core/utils/format.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../models/chat_info.dart';
|
import '../../../models/chat_info.dart';
|
||||||
import '../../../models/contact_info.dart';
|
import '../../../models/contact_info.dart';
|
||||||
|
import '../../widgets/avatar_history_screen.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/glossy_pill.dart';
|
import '../../widgets/glossy_pill.dart';
|
||||||
import '../../widgets/komet_avatar.dart';
|
import '../../widgets/komet_avatar.dart';
|
||||||
@@ -1130,12 +1131,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _avatar() {
|
Widget _avatar() {
|
||||||
return KometAvatar(
|
final avatar = KometAvatar(
|
||||||
name: widget.name,
|
name: widget.name,
|
||||||
imageUrl: widget.imageUrl,
|
imageUrl: widget.imageUrl,
|
||||||
size: 96,
|
size: 96,
|
||||||
fontSize: 36,
|
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) {
|
Widget _buildShimmer(ColorScheme cs) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../core/storage/token_storage.dart';
|
|||||||
import '../../../core/utils/format.dart';
|
import '../../../core/utils/format.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../models/contact_info.dart';
|
import '../../../models/contact_info.dart';
|
||||||
|
import '../../widgets/avatar_history_screen.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/glossy_pill.dart';
|
import '../../widgets/glossy_pill.dart';
|
||||||
import '../../widgets/komet_avatar.dart';
|
import '../../widgets/komet_avatar.dart';
|
||||||
@@ -146,11 +147,19 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
KometAvatar(
|
GestureDetector(
|
||||||
name: _displayName(),
|
onTap: () => AvatarHistoryScreen.open(
|
||||||
imageUrl: _avatarUrl(),
|
context,
|
||||||
size: 96,
|
contactId: widget.contactId,
|
||||||
fontSize: 36,
|
name: _displayName(),
|
||||||
|
currentAvatarUrl: _avatarUrl(),
|
||||||
|
),
|
||||||
|
child: KometAvatar(
|
||||||
|
name: _displayName(),
|
||||||
|
imageUrl: _avatarUrl(),
|
||||||
|
size: 96,
|
||||||
|
fontSize: 36,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildNameRow(cs),
|
_buildNameRow(cs),
|
||||||
@@ -206,7 +215,11 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
|||||||
label: l10n.contactProfileActionChat,
|
label: l10n.contactProfileActionChat,
|
||||||
onTap: _openChat,
|
onTap: _openChat,
|
||||||
),
|
),
|
||||||
(icon: Symbols.notifications, label: l10n.contactProfileActionSound, onTap: null),
|
(
|
||||||
|
icon: Symbols.notifications,
|
||||||
|
label: l10n.contactProfileActionSound,
|
||||||
|
onTap: null,
|
||||||
|
),
|
||||||
if (!_isBot)
|
if (!_isBot)
|
||||||
(icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null),
|
(icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null),
|
||||||
];
|
];
|
||||||
@@ -250,17 +263,23 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
|||||||
|
|
||||||
final phoneStr = formatPhone(c.raw['phone']);
|
final phoneStr = formatPhone(c.raw['phone']);
|
||||||
if (phoneStr != null) {
|
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?;
|
final country = c.raw['country'] as String?;
|
||||||
if (country != null && country.isNotEmpty) {
|
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']);
|
final genderStr = formatGender(c.raw['gender']);
|
||||||
if (genderStr != null) {
|
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?;
|
final regTime = c.raw['registrationTime'] as int?;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
endpoint: TrafficMonitor.instance.activeEndpoint,
|
endpoint: TrafficMonitor.instance.activeEndpoint,
|
||||||
);
|
);
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
if (mounted) showCustomNotification(context, 'Нет запросов для лога');
|
if (mounted) showCustomNotification(context, 'Лог пуст');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final bytes = Uint8List.fromList(utf8.encode(content));
|
final bytes = Uint8List.fromList(utf8.encode(content));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart';
|
|||||||
import '../../../core/utils/format.dart';
|
import '../../../core/utils/format.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
|
import '../../widgets/avatar_history_screen.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/info_action_sheet.dart';
|
import '../../widgets/info_action_sheet.dart';
|
||||||
import '../../widgets/komet_avatar.dart';
|
import '../../widgets/komet_avatar.dart';
|
||||||
@@ -557,21 +558,29 @@ class _SettingsTabState extends State<SettingsTab> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
GestureDetector(
|
||||||
width: 88,
|
onTap: () => AvatarHistoryScreen.open(
|
||||||
height: 88,
|
context,
|
||||||
decoration: BoxDecoration(
|
contactId: _profile?.id ?? 0,
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: cs.primary.withValues(alpha: 0.5),
|
|
||||||
width: 2.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: KometAvatar(
|
|
||||||
name: name,
|
name: name,
|
||||||
imageUrl: _profile?.baseUrl,
|
currentAvatarUrl: _profile?.baseUrl,
|
||||||
size: 88,
|
),
|
||||||
fontSize: 32,
|
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),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,8 +105,47 @@ Future<Locale> _loadInitialLocale() async {
|
|||||||
return const Locale('ru');
|
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 {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
_installLogCapture();
|
||||||
VideoPlayerMediaKit.ensureInitialized(
|
VideoPlayerMediaKit.ensureInitialized(
|
||||||
windows: true,
|
windows: true,
|
||||||
linux: true,
|
linux: true,
|
||||||
|
|||||||
+1
-1
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
Reference in New Issue
Block a user