небольшие изменения
This commit is contained in:
+4405
File diff suppressed because it is too large
Load Diff
+83
-33
@@ -51,6 +51,8 @@ class Api {
|
||||
|
||||
String? spoofScope;
|
||||
|
||||
static bool _tzInitialized = false;
|
||||
|
||||
List<CountryName>? _registrationCountries;
|
||||
|
||||
List<CountryName> get registrationCountries =>
|
||||
@@ -99,8 +101,9 @@ class Api {
|
||||
final useBypass = _bypassActive && bypassArmed;
|
||||
// Попытку через VPN ограничиваем по времени, чтобы быстро понять,
|
||||
// что туннель не пропускает, и переключиться на обход.
|
||||
final attemptTimeout =
|
||||
bypassArmed && !useBypass ? const Duration(seconds: 8) : null;
|
||||
final attemptTimeout = bypassArmed && !useBypass
|
||||
? const Duration(seconds: 8)
|
||||
: null;
|
||||
|
||||
try {
|
||||
final endpoint = await ServerConfig.loadEndpoint();
|
||||
@@ -111,13 +114,13 @@ class Api {
|
||||
timeout: attemptTimeout,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e('Не удалось подключиться: $e');
|
||||
if (_sessionState != SessionState.disconnected) {
|
||||
_cleanup();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
_armBypassIfPossible(bypassArmed, useBypass, 'подключение не удалось');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
await _handleConnectFailure(
|
||||
e,
|
||||
phase: 'Не удалось подключиться',
|
||||
bypassArmed: bypassArmed,
|
||||
useBypass: useBypass,
|
||||
bypassWhy: 'подключение не удалось',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,16 +153,32 @@ class Api {
|
||||
logger.e('Хэндшейк отклонён: ${response.payload}');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e('Ошибка хэндшейка: $e');
|
||||
// Сокет подключился (через VPN), но сервер не ответил на хэндшейк —
|
||||
// путь нерабочий: рвём соединение и пробуем мимо VPN.
|
||||
if (_sessionState != SessionState.disconnected) {
|
||||
_cleanup();
|
||||
await _connection.disconnect();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
_armBypassIfPossible(bypassArmed, useBypass, 'хэндшейк не прошёл');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
await _handleConnectFailure(
|
||||
e,
|
||||
phase: 'Ошибка хэндшейка',
|
||||
bypassArmed: bypassArmed,
|
||||
useBypass: useBypass,
|
||||
bypassWhy: 'хэндшейк не прошёл',
|
||||
disconnectSocket: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleConnectFailure(
|
||||
Object error, {
|
||||
required String phase,
|
||||
required bool bypassArmed,
|
||||
required bool useBypass,
|
||||
required String bypassWhy,
|
||||
bool disconnectSocket = false,
|
||||
}) async {
|
||||
logger.e('$phase: $error');
|
||||
if (_sessionState != SessionState.disconnected) {
|
||||
_cleanup();
|
||||
if (disconnectSocket) await _connection.disconnect();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
_armBypassIfPossible(bypassArmed, useBypass, bypassWhy);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +225,10 @@ class Api {
|
||||
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
||||
String screen = '420dpi 420dpi 1080x2340';
|
||||
|
||||
tz.initializeTimeZones();
|
||||
if (!_tzInitialized) {
|
||||
tz.initializeTimeZones();
|
||||
_tzInitialized = true;
|
||||
}
|
||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||
String timezone = timeZoneName.identifier;
|
||||
String locale = 'ru';
|
||||
@@ -219,10 +241,7 @@ class Api {
|
||||
if (Platform.isLinux) {
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
osVersion = linuxInfo.name;
|
||||
architecture = Platform.version.substring(
|
||||
Platform.version.indexOf('_') + 1,
|
||||
Platform.version.length - 1,
|
||||
);
|
||||
architecture = _archFromPlatformVersion();
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
osVersion = iosInfo.systemVersion;
|
||||
@@ -235,10 +254,7 @@ class Api {
|
||||
} else if (Platform.isWindows) {
|
||||
final windowsInfo = await deviceInfo.windowsInfo;
|
||||
osVersion = windowsInfo.productName;
|
||||
architecture = Platform.version.substring(
|
||||
Platform.version.indexOf('_') + 1,
|
||||
Platform.version.length - 1,
|
||||
);
|
||||
architecture = _archFromPlatformVersion();
|
||||
}
|
||||
|
||||
final spoofed = await SpoofingService.getSpoofedSessionData(
|
||||
@@ -339,6 +355,29 @@ class Api {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<dynamic, dynamic>?> sendRequestMap(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload,
|
||||
) async {
|
||||
final response = await sendRequest(opcode, payload);
|
||||
if (!response.isOk || response.payload is! Map) return null;
|
||||
return response.payload as Map<dynamic, dynamic>;
|
||||
}
|
||||
|
||||
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async {
|
||||
final response = await sendRequest(opcode, payload);
|
||||
return response.isOk;
|
||||
}
|
||||
|
||||
Future<Packet> sendRequestOrThrow(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload,
|
||||
) async {
|
||||
final response = await sendRequest(opcode, payload);
|
||||
throwIfPacketError(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Вешает обработчик на пуши с указанным опкодом.
|
||||
void registerPushHandler(int opcode, void Function(Packet) handler) {
|
||||
_dispatcher.registerHandler(opcode, handler);
|
||||
@@ -373,7 +412,16 @@ class Api {
|
||||
}
|
||||
|
||||
Future<void> _onDataReceived(Uint8List data) async {
|
||||
final rawPackets = _receiver.feed(data);
|
||||
final List<Uint8List> rawPackets;
|
||||
try {
|
||||
rawPackets = _receiver.feed(data);
|
||||
} on ReceiverOverflowException catch (e) {
|
||||
logger.e('$e — форсируем реконнект');
|
||||
if (_sessionState != SessionState.disconnected) {
|
||||
unawaited(_forceReconnect());
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (final raw in rawPackets) {
|
||||
final Packet packet;
|
||||
try {
|
||||
@@ -383,10 +431,7 @@ class Api {
|
||||
continue;
|
||||
}
|
||||
TrafficMonitor.instance.recordIncoming(packet, raw.length);
|
||||
if (packet.isError &&
|
||||
packet.payload is Map &&
|
||||
(packet.payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
||||
packet.payload['message'] == 'FAIL_WRONG_PASSWORD')) {
|
||||
if (packet.isError && isSessionExpiredPayload(packet.payload)) {
|
||||
_sessionExpiredController.add(
|
||||
SessionExpiredException(messageFromErrorPayload(packet.payload)),
|
||||
);
|
||||
@@ -465,6 +510,11 @@ class Api {
|
||||
}
|
||||
}
|
||||
|
||||
static String _archFromPlatformVersion() {
|
||||
final v = Platform.version;
|
||||
return v.substring(v.indexOf('_') + 1, v.length - 1);
|
||||
}
|
||||
|
||||
static List<CountryName>? _parseRegistrationCountries(dynamic payload) {
|
||||
if (payload is! Map) return null;
|
||||
final raw = payload['reg-country-code'];
|
||||
|
||||
@@ -23,47 +23,39 @@ class ChatFolder {
|
||||
this.options,
|
||||
});
|
||||
|
||||
static List<int>? _parseIntList(dynamic raw) {
|
||||
return (raw as List<dynamic>?)?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
factory ChatFolder.fromJson(Map<String, dynamic> json) {
|
||||
return ChatFolder(
|
||||
id: json['id']?.toString() ?? '',
|
||||
title: json['title']?.toString() ?? '',
|
||||
emoji: json['emoji']?.toString(),
|
||||
include: (json['include'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
})
|
||||
.toList(),
|
||||
include: _parseIntList(json['include']),
|
||||
filters:
|
||||
(json['filters'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? e;
|
||||
return e;
|
||||
})
|
||||
.toList() ??
|
||||
(json['filters'] as List<dynamic>?)?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? e;
|
||||
return e;
|
||||
}).toList() ??
|
||||
[],
|
||||
hideEmpty: json['hideEmpty'] ?? false,
|
||||
widgets:
|
||||
(json['widgets'] as List<dynamic>?)
|
||||
?.map((w) {
|
||||
if (w is Map<String, dynamic>) {
|
||||
return ChatFolderWidget.fromJson(w);
|
||||
}
|
||||
return ChatFolderWidget.fromJson(
|
||||
Map<String, dynamic>.from(w as Map),
|
||||
);
|
||||
})
|
||||
.toList() ??
|
||||
(json['widgets'] as List<dynamic>?)?.map((w) {
|
||||
if (w is Map<String, dynamic>) {
|
||||
return ChatFolderWidget.fromJson(w);
|
||||
}
|
||||
return ChatFolderWidget.fromJson(
|
||||
Map<String, dynamic>.from(w as Map),
|
||||
);
|
||||
}).toList() ??
|
||||
[],
|
||||
favorites: (json['favorites'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
})
|
||||
.toList(),
|
||||
favorites: _parseIntList(json['favorites']),
|
||||
filterSubjects: json['filterSubjects'] is Map<String, dynamic>
|
||||
? json['filterSubjects'] as Map<String, dynamic>
|
||||
: (json['filterSubjects'] is Map
|
||||
@@ -71,13 +63,7 @@ class ChatFolder {
|
||||
(json['filterSubjects'] as Map).cast<dynamic, dynamic>(),
|
||||
)
|
||||
: null),
|
||||
options: (json['options'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
})
|
||||
.toList(),
|
||||
options: _parseIntList(json['options']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+168
-909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import '../../api.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
|
||||
abstract class AccountApiBase {
|
||||
final Api api;
|
||||
const AccountApiBase(this.api);
|
||||
|
||||
void ensureOnline() {
|
||||
if (api.state != SessionState.online) {
|
||||
throw StateError(
|
||||
'AccountModule: сессия не онлайн (текущее состояние: ${api.state.name})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void checkPacketError(Packet packet, String method) {
|
||||
throwIfPacketError(packet);
|
||||
}
|
||||
|
||||
Map requireMapPayload(Packet packet, String method) {
|
||||
checkPacketError(packet, method);
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('$method: неожиданный тип payload: ${data.runtimeType}');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../core/storage/app_database.dart';
|
||||
|
||||
class PrivacyConfig {
|
||||
final String searchByPhone;
|
||||
final String incomingCall;
|
||||
final bool doubleTapReactionDisabled;
|
||||
final bool safeModeNoPin;
|
||||
final String? doubleTapReactionValue;
|
||||
final String familyProtection;
|
||||
final bool pushDetails;
|
||||
final bool hidden;
|
||||
final String chatsInvite;
|
||||
final bool pushNewContacts;
|
||||
final bool unsafeFiles;
|
||||
final String phoneNumberPrivacy;
|
||||
final String inactiveTtl;
|
||||
final bool showReadMark;
|
||||
final bool altKeyboard;
|
||||
final bool contentLevelAccess;
|
||||
final String stickersSuggest;
|
||||
final bool safeMode;
|
||||
final bool audioTranscriptionEnabled;
|
||||
final String chatsPushNotification;
|
||||
final String mCallPushNotification;
|
||||
final String pushSound;
|
||||
final String chatsPushSound;
|
||||
final String hash;
|
||||
|
||||
const PrivacyConfig({
|
||||
required this.searchByPhone,
|
||||
required this.incomingCall,
|
||||
required this.doubleTapReactionDisabled,
|
||||
required this.safeModeNoPin,
|
||||
this.doubleTapReactionValue,
|
||||
required this.familyProtection,
|
||||
required this.pushDetails,
|
||||
required this.hidden,
|
||||
required this.chatsInvite,
|
||||
required this.pushNewContacts,
|
||||
required this.unsafeFiles,
|
||||
required this.phoneNumberPrivacy,
|
||||
required this.inactiveTtl,
|
||||
required this.showReadMark,
|
||||
required this.altKeyboard,
|
||||
required this.contentLevelAccess,
|
||||
required this.stickersSuggest,
|
||||
required this.safeMode,
|
||||
required this.audioTranscriptionEnabled,
|
||||
required this.chatsPushNotification,
|
||||
required this.mCallPushNotification,
|
||||
required this.pushSound,
|
||||
required this.chatsPushSound,
|
||||
required this.hash,
|
||||
});
|
||||
|
||||
factory PrivacyConfig.fromMap(Map<dynamic, dynamic> map) {
|
||||
return PrivacyConfig(
|
||||
searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL',
|
||||
incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS',
|
||||
doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false,
|
||||
safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false,
|
||||
doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(),
|
||||
familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF',
|
||||
pushDetails: map['PUSH_DETAILS'] ?? false,
|
||||
hidden: map['HIDDEN'] ?? true,
|
||||
chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS',
|
||||
pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false,
|
||||
unsafeFiles: map['UNSAFE_FILES'] ?? true,
|
||||
phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL',
|
||||
inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M',
|
||||
showReadMark: map['SHOW_READ_MARK'] ?? true,
|
||||
altKeyboard: map['ALT_KEYBOARD'] ?? false,
|
||||
contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false,
|
||||
stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON',
|
||||
safeMode: map['SAFE_MODE'] ?? false,
|
||||
audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true,
|
||||
chatsPushNotification: map['CHATS_PUSH_NOTIFICATION']?.toString() ?? 'ON',
|
||||
mCallPushNotification:
|
||||
map['M_CALL_PUSH_NOTIFICATION']?.toString() ?? 'ON',
|
||||
pushSound: map['PUSH_SOUND']?.toString() ?? 'oki.aiff',
|
||||
chatsPushSound: map['CHATS_PUSH_SOUND']?.toString() ?? 'oki.aiff',
|
||||
hash: map['hash']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => jsonEncode({
|
||||
'SEARCH_BY_PHONE': searchByPhone,
|
||||
'INCOMING_CALL': incomingCall,
|
||||
'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled,
|
||||
'SAFE_MODE_NO_PIN': safeModeNoPin,
|
||||
'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue,
|
||||
'FAMILY_PROTECTION': familyProtection,
|
||||
'PUSH_DETAILS': pushDetails,
|
||||
'HIDDEN': hidden,
|
||||
'CHATS_INVITE': chatsInvite,
|
||||
'PUSH_NEW_CONTACTS': pushNewContacts,
|
||||
'UNSAFE_FILES': unsafeFiles,
|
||||
'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy,
|
||||
'INACTIVE_TTL': inactiveTtl,
|
||||
'SHOW_READ_MARK': showReadMark,
|
||||
'ALT_KEYBOARD': altKeyboard,
|
||||
'CONTENT_LEVEL_ACCESS': contentLevelAccess,
|
||||
'STICKERS_SUGGEST': stickersSuggest,
|
||||
'SAFE_MODE': safeMode,
|
||||
'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled,
|
||||
'CHATS_PUSH_NOTIFICATION': chatsPushNotification,
|
||||
'M_CALL_PUSH_NOTIFICATION': mCallPushNotification,
|
||||
'PUSH_SOUND': pushSound,
|
||||
'CHATS_PUSH_SOUND': chatsPushSound,
|
||||
'hash': hash,
|
||||
});
|
||||
|
||||
factory PrivacyConfig.fromJson(String json) {
|
||||
try {
|
||||
final map = jsonDecode(json) as Map<String, dynamic>;
|
||||
return PrivacyConfig.fromMap(map);
|
||||
} catch (_) {
|
||||
return PrivacyConfig.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static PrivacyConfig empty() {
|
||||
return const PrivacyConfig(
|
||||
searchByPhone: 'ALL',
|
||||
incomingCall: 'CONTACTS',
|
||||
doubleTapReactionDisabled: false,
|
||||
safeModeNoPin: false,
|
||||
familyProtection: 'OFF',
|
||||
pushDetails: false,
|
||||
hidden: true,
|
||||
chatsInvite: 'CONTACTS',
|
||||
pushNewContacts: false,
|
||||
unsafeFiles: true,
|
||||
phoneNumberPrivacy: 'ALL',
|
||||
inactiveTtl: '6M',
|
||||
showReadMark: true,
|
||||
altKeyboard: false,
|
||||
contentLevelAccess: false,
|
||||
stickersSuggest: 'ON',
|
||||
safeMode: false,
|
||||
audioTranscriptionEnabled: true,
|
||||
chatsPushNotification: 'ON',
|
||||
mCallPushNotification: 'ON',
|
||||
pushSound: 'oki.aiff',
|
||||
chatsPushSound: 'oki.aiff',
|
||||
hash: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BlockedContact {
|
||||
final int id;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? baseUrl;
|
||||
final int? photoId;
|
||||
final String status;
|
||||
final int registrationTime;
|
||||
final int updateTime;
|
||||
|
||||
const BlockedContact({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.baseUrl,
|
||||
this.photoId,
|
||||
required this.status,
|
||||
required this.registrationTime,
|
||||
required this.updateTime,
|
||||
});
|
||||
|
||||
factory BlockedContact.fromMap(Map<dynamic, dynamic> map) {
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
final names = map['names'] as List?;
|
||||
if (names != null && names.isNotEmpty) {
|
||||
for (final n in names) {
|
||||
if (n is Map) {
|
||||
firstName = n['firstName'] as String?;
|
||||
lastName = n['lastName'] as String?;
|
||||
if (n['type'] == 'ONEME') break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BlockedContact(
|
||||
id: map['id'] as int? ?? 0,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
photoId: map['photoId'] as int?,
|
||||
status: map['status']?.toString() ?? 'BLOCKED',
|
||||
registrationTime: map['registrationTime'] as int? ?? 0,
|
||||
updateTime: map['updateTime'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TwoFactorDetails {
|
||||
final bool enabled;
|
||||
final String? email;
|
||||
final String? hint;
|
||||
|
||||
const TwoFactorDetails({required this.enabled, this.email, this.hint});
|
||||
}
|
||||
|
||||
enum AuthRequestType {
|
||||
startAuth('START_AUTH'),
|
||||
resend('RESEND'),
|
||||
checkCode('CHECK_CODE'),
|
||||
register('REGISTER');
|
||||
|
||||
const AuthRequestType(this.value);
|
||||
final String value;
|
||||
}
|
||||
|
||||
enum LoginStatus { idle, loading, success, error }
|
||||
|
||||
class WrongDeviceTokenException implements Exception {
|
||||
const WrongDeviceTokenException();
|
||||
@override
|
||||
String toString() => 'WrongDeviceTokenException';
|
||||
}
|
||||
|
||||
class RequestCodeResult {
|
||||
final String token;
|
||||
|
||||
const RequestCodeResult({required this.token});
|
||||
}
|
||||
|
||||
class PresetAvatar {
|
||||
final int id;
|
||||
final String url;
|
||||
|
||||
const PresetAvatar({required this.id, required this.url});
|
||||
}
|
||||
|
||||
class PresetAvatarCategory {
|
||||
final String name;
|
||||
final List<PresetAvatar> avatars;
|
||||
|
||||
const PresetAvatarCategory({required this.name, required this.avatars});
|
||||
}
|
||||
|
||||
class VerifyCodeResult {
|
||||
final Map<dynamic, dynamic> payload;
|
||||
|
||||
const VerifyCodeResult({required this.payload});
|
||||
|
||||
String? get loginToken => _nestedToken('LOGIN');
|
||||
|
||||
String? get registerToken => _nestedToken('REGISTER');
|
||||
|
||||
bool get isRegistration => registerToken != null && loginToken == null;
|
||||
|
||||
List<PresetAvatarCategory> get presetAvatars {
|
||||
final raw = payload['presetAvatars'];
|
||||
if (raw is! List) return const [];
|
||||
final categories = <PresetAvatarCategory>[];
|
||||
for (final cat in raw) {
|
||||
if (cat is! Map) continue;
|
||||
final avatarsRaw = cat['avatars'];
|
||||
if (avatarsRaw is! List) continue;
|
||||
final avatars = <PresetAvatar>[];
|
||||
for (final a in avatarsRaw) {
|
||||
if (a is! Map) continue;
|
||||
final id = a['id'];
|
||||
final url = a['url'];
|
||||
if (id is int && url is String && url.isNotEmpty) {
|
||||
avatars.add(PresetAvatar(id: id, url: url));
|
||||
}
|
||||
}
|
||||
if (avatars.isNotEmpty) {
|
||||
categories.add(
|
||||
PresetAvatarCategory(
|
||||
name: cat['name']?.toString() ?? '',
|
||||
avatars: avatars,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
bool get requiresPassword => payload['passwordChallenge'] != null;
|
||||
|
||||
Map<dynamic, dynamic>? get passwordChallenge {
|
||||
final c = payload['passwordChallenge'];
|
||||
return c is Map ? c.cast<dynamic, dynamic>() : null;
|
||||
}
|
||||
|
||||
String? get challengeTrackId => passwordChallenge?['trackId'] as String?;
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
String? _nestedToken(String key) {
|
||||
final attrs = payload['tokenAttrs'];
|
||||
if (attrs is! Map) return null;
|
||||
final entry = attrs[key];
|
||||
if (entry is! Map) return null;
|
||||
return entry['token'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
class TwoFactorResult {
|
||||
final String loginToken;
|
||||
|
||||
const TwoFactorResult({required this.loginToken});
|
||||
}
|
||||
|
||||
class LoginSyncParams {
|
||||
final int chatsSync;
|
||||
final int contactsSync;
|
||||
final int callsSync;
|
||||
final int draftsSync;
|
||||
final int bannersSync;
|
||||
final int presenceSync;
|
||||
final int lastLogin;
|
||||
final String? configHash;
|
||||
final String? chatCacheFingerprint;
|
||||
|
||||
const LoginSyncParams({
|
||||
required this.chatsSync,
|
||||
required this.contactsSync,
|
||||
required this.callsSync,
|
||||
required this.draftsSync,
|
||||
required this.bannersSync,
|
||||
required this.presenceSync,
|
||||
required this.lastLogin,
|
||||
this.configHash,
|
||||
this.chatCacheFingerprint,
|
||||
});
|
||||
|
||||
static Future<LoginSyncParams?> fromDatabase(int accountId) async {
|
||||
final values = await AppDatabase.getAllSyncValues(accountId);
|
||||
final lastLogin = values[SyncKey.lastLogin];
|
||||
if (lastLogin == null) return null;
|
||||
|
||||
return LoginSyncParams(
|
||||
chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0,
|
||||
contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0,
|
||||
callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0,
|
||||
draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0,
|
||||
bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0,
|
||||
presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1,
|
||||
lastLogin: int.tryParse(lastLogin) ?? 0,
|
||||
configHash: values[SyncKey.configHash],
|
||||
chatCacheFingerprint: values[SyncKey.chatCacheFingerprint],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionInfo {
|
||||
final int? id;
|
||||
final String client;
|
||||
final String location;
|
||||
final bool current;
|
||||
final int time;
|
||||
final String info;
|
||||
|
||||
const SessionInfo({
|
||||
this.id,
|
||||
required this.client,
|
||||
required this.location,
|
||||
required this.current,
|
||||
required this.time,
|
||||
required this.info,
|
||||
});
|
||||
|
||||
factory SessionInfo.fromMap(Map<dynamic, dynamic> map) {
|
||||
return SessionInfo(
|
||||
id: map['id'] is int
|
||||
? map['id']
|
||||
: (int.tryParse(map['id']?.toString() ?? '')),
|
||||
client: map['client'] ?? '',
|
||||
location: map['location'] ?? '',
|
||||
current: map['current'] ?? false,
|
||||
time: map['time'] ?? 0,
|
||||
info: map['info'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
int get uniqueId => Object.hash(id, client, time, info);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SessionInfo &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
client == other.client &&
|
||||
location == other.location &&
|
||||
current == other.current &&
|
||||
time == other.time &&
|
||||
info == other.info;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, client, location, current, time, info);
|
||||
}
|
||||
|
||||
class LoginResult {
|
||||
final ProfileData profile;
|
||||
final String? updatedToken;
|
||||
final int serverTime;
|
||||
final Map<dynamic, dynamic> raw;
|
||||
|
||||
const LoginResult({
|
||||
required this.profile,
|
||||
required this.updatedToken,
|
||||
required this.serverTime,
|
||||
required this.raw,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import '../../api.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import 'account_base.dart';
|
||||
import 'account_models.dart';
|
||||
|
||||
class PrivacyModule extends AccountApiBase {
|
||||
PrivacyModule(super.api);
|
||||
|
||||
static const String _defaultPushSound = 'oki.aiff';
|
||||
|
||||
Future<PrivacyConfig> getPrivacyConfig() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
final saved = await AppDatabase.getPrivacyConfig(accountId);
|
||||
if (saved != null) return PrivacyConfig.fromJson(saved);
|
||||
}
|
||||
return PrivacyConfig.empty();
|
||||
}
|
||||
|
||||
Future<List<BlockedContact>> getBlockedContacts() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.contactList, {
|
||||
'status': 'BLOCKED',
|
||||
'count': 100,
|
||||
'from': 0,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'getBlockedContacts');
|
||||
final contacts = data['contacts'] as List?;
|
||||
if (contacts == null) return [];
|
||||
return contacts
|
||||
.whereType<Map>()
|
||||
.map((c) => BlockedContact.fromMap(c.cast<dynamic, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<PrivacyConfig> updatePrivacyConfig(
|
||||
Map<String, dynamic> settings,
|
||||
) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'settings': {'user': settings},
|
||||
};
|
||||
final packet = await api.sendRequest(Opcode.config, payload);
|
||||
final data = requireMapPayload(packet, 'updatePrivacyConfig');
|
||||
final user = data['user'];
|
||||
if (user is! Map) {
|
||||
throw Exception('updatePrivacyConfig: отсутствует user в payload');
|
||||
}
|
||||
final config = PrivacyConfig.fromMap(user.cast<dynamic, dynamic>());
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.savePrivacyConfig(accountId, config.toJson());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
Future<PrivacyConfig> setChatsPushNotification(bool value) =>
|
||||
updatePrivacyConfig({'CHATS_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'});
|
||||
|
||||
Future<PrivacyConfig> setMessagePreview(bool value) =>
|
||||
updatePrivacyConfig({'PUSH_DETAILS': value});
|
||||
|
||||
Future<PrivacyConfig> setNotificationSound(bool value) =>
|
||||
updatePrivacyConfig({
|
||||
'PUSH_SOUND': value ? _defaultPushSound : '',
|
||||
'CHATS_PUSH_SOUND': value ? _defaultPushSound : '',
|
||||
});
|
||||
|
||||
Future<PrivacyConfig> setCallNotifications(bool value) =>
|
||||
updatePrivacyConfig({'M_CALL_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'});
|
||||
|
||||
Future<PrivacyConfig> setNewContacts(bool value) =>
|
||||
updatePrivacyConfig({'PUSH_NEW_CONTACTS': value});
|
||||
|
||||
Future<void> registerPushToken(String pushToken) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.config, <dynamic, dynamic>{
|
||||
'pushToken': pushToken,
|
||||
'pushOptions': 0,
|
||||
});
|
||||
if (packet.isError) {
|
||||
final msg = messageFromErrorPayload(packet.payload).toUpperCase();
|
||||
if (msg.contains('WRONG_DEVICE_TOKEN') ||
|
||||
msg.contains('WRONG.DEVICE.TOKEN')) {
|
||||
throw const WrongDeviceTokenException();
|
||||
}
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unregisterPushToken(String pushToken) async {
|
||||
if (api.state != SessionState.online) return;
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
final authToken = await TokenStorage.readToken(accountId);
|
||||
if (authToken == null) return;
|
||||
await api.sendRequest(Opcode.logout, <dynamic, dynamic>{
|
||||
'token': authToken,
|
||||
'pushToken': pushToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import 'account_base.dart';
|
||||
|
||||
class ProfileModule extends AccountApiBase {
|
||||
ProfileModule(super.api);
|
||||
|
||||
Future<ProfileData> _applyProfileResponse(Packet packet) async {
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final profile = data['profile'] as Map?;
|
||||
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>(),
|
||||
);
|
||||
await AppDatabase.saveProfile(newProfile, isActive: true);
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileName(
|
||||
String firstName,
|
||||
String? lastName,
|
||||
) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{'firstName': firstName};
|
||||
if (lastName != null) payload['lastName'] = lastName;
|
||||
final packet = await api.sendRequest(Opcode.profile, payload);
|
||||
return _applyProfileResponse(packet);
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileAvatar(
|
||||
String photoToken, {
|
||||
String avatarType = 'USER_AVATAR',
|
||||
}) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.profile, {
|
||||
'photoToken': photoToken,
|
||||
'avatarType': avatarType,
|
||||
});
|
||||
return _applyProfileResponse(packet);
|
||||
}
|
||||
|
||||
Future<String> getAvatarUploadUrl() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.photoUpload, {
|
||||
'count': 1,
|
||||
'profile': true,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final url = data['url'] as String?;
|
||||
if (url == null) throw Exception('No url in response');
|
||||
return url;
|
||||
}
|
||||
|
||||
Future<ProfileData> removeProfilePhoto(int photoId) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.removeContactPhoto, {
|
||||
'photoId': photoId,
|
||||
});
|
||||
return _applyProfileResponse(packet);
|
||||
}
|
||||
|
||||
Future<ProfileData> processProfileUpdate(
|
||||
Future<Packet> requestFuture,
|
||||
String tag,
|
||||
) async {
|
||||
final completer = Completer<ProfileData>();
|
||||
final sub = api.pushStream
|
||||
.where((p) => p.opcode == Opcode.notifProfile)
|
||||
.listen((push) {
|
||||
if (completer.isCompleted) return;
|
||||
final payload = push.payload;
|
||||
if (payload is! Map) return;
|
||||
final profile = payload['profile'];
|
||||
if (profile is! Map) return;
|
||||
final contact = profile['contact'];
|
||||
if (contact is! Map) return;
|
||||
completer.complete(
|
||||
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()),
|
||||
);
|
||||
});
|
||||
final timer = Timer(const Duration(seconds: 15), () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
Exception('Таймаут ожидания обновления профиля'),
|
||||
);
|
||||
}
|
||||
});
|
||||
try {
|
||||
final packet = await requestFuture;
|
||||
checkPacketError(packet, tag);
|
||||
return await completer.future;
|
||||
} finally {
|
||||
timer.cancel();
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import 'account_base.dart';
|
||||
import 'account_models.dart';
|
||||
|
||||
class SessionsModule extends AccountApiBase {
|
||||
SessionsModule(super.api);
|
||||
|
||||
Future<List<SessionInfo>> getSessions() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.sessionsInfo, {});
|
||||
checkPacketError(packet, 'getSessions');
|
||||
final data = packet.payload;
|
||||
if (data is! Map || data['sessions'] is! List) return [];
|
||||
final sessions = data['sessions'] as List;
|
||||
return sessions
|
||||
.map((s) => SessionInfo.fromMap(s as Map<dynamic, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> terminateOtherSessions() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.sessionsClose, {});
|
||||
checkPacketError(packet, 'terminateOtherSessions');
|
||||
}
|
||||
|
||||
Future<void> authorizeWebQrLogin(String qrLink) async {
|
||||
ensureOnline();
|
||||
final link = qrLink.trim();
|
||||
if (link.isEmpty) {
|
||||
throw ArgumentError('Пустая ссылка из QR');
|
||||
}
|
||||
|
||||
await api.sendRequest(Opcode.ping, {'interactive': true});
|
||||
await api.sendRequest(Opcode.sessionsInfo, {});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
final packet = await api.sendRequest(Opcode.authQrApprove, {
|
||||
'qrLink': link,
|
||||
});
|
||||
checkPacketError(packet, 'authorizeWebQrLogin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import 'account_base.dart';
|
||||
import 'account_models.dart';
|
||||
import 'profile_module.dart';
|
||||
|
||||
class TwoFactorModule extends AccountApiBase {
|
||||
final ProfileModule _profile;
|
||||
TwoFactorModule(super.api, this._profile);
|
||||
|
||||
Future<String> create2faTrack() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0});
|
||||
final data = requireMapPayload(packet, 'create2faTrack');
|
||||
final trackId = data['trackId'] as String?;
|
||||
if (trackId == null) {
|
||||
throw Exception('create2faTrack: отсутствует trackId');
|
||||
}
|
||||
return trackId;
|
||||
}
|
||||
|
||||
Future<void> set2faPassword(String trackId, String password) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authValidatePassword, {
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
});
|
||||
checkPacketError(packet, 'set2faPassword');
|
||||
if (packet.payload != null && packet.payload is! Map) {
|
||||
throw Exception('set2faPassword: неожиданный ответ');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set2faHint(String trackId, String hint) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authValidateHint, {
|
||||
'trackId': trackId,
|
||||
'hint': hint,
|
||||
});
|
||||
checkPacketError(packet, 'set2faHint');
|
||||
if (packet.payload != null && packet.payload is! Map) {
|
||||
throw Exception('set2faHint: неожиданный ответ');
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> verify2faEmail(String trackId, String email) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authVerifyEmail, {
|
||||
'trackId': trackId,
|
||||
'email': email,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'verify2faEmail');
|
||||
final blockingDuration = data['blockingDuration'] as int? ?? 60;
|
||||
return blockingDuration;
|
||||
}
|
||||
|
||||
Future<String> verify2faCode(String trackId, String code) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCheckEmail, {
|
||||
'trackId': trackId,
|
||||
'verifyCode': code,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'verify2faCode');
|
||||
final email = data['email'] as String? ?? '';
|
||||
return email;
|
||||
}
|
||||
|
||||
Future<ProfileData> confirm2fa({
|
||||
required String trackId,
|
||||
required String password,
|
||||
String? hint,
|
||||
bool withEmail = true,
|
||||
}) async {
|
||||
ensureOnline();
|
||||
final capabilities = <int>[0, if (hint != null) 3, if (withEmail) 4];
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': capabilities,
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'confirm2fa',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> enter2faPanel() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0});
|
||||
final data = requireMapPayload(packet, 'enter2faPanel');
|
||||
final trackId = data['trackId'] as String?;
|
||||
if (trackId == null) {
|
||||
throw Exception('enter2faPanel: отсутствует trackId');
|
||||
}
|
||||
return trackId;
|
||||
}
|
||||
|
||||
Future<TwoFactorDetails> get2faDetails(String trackId) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.auth2faDetails, {
|
||||
'trackId': trackId,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'get2faDetails');
|
||||
final password = data['password'] as Map?;
|
||||
return TwoFactorDetails(
|
||||
enabled: password?['enabled'] ?? false,
|
||||
email: password?['email'] as String?,
|
||||
hint: password?['hint'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TwoFactorDetails> get2faStatus() async {
|
||||
final trackId = await enter2faPanel();
|
||||
return get2faDetails(trackId);
|
||||
}
|
||||
|
||||
Future<void> check2faPassword(String trackId, String password) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCheckPassword, {
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
});
|
||||
checkPacketError(packet, 'check2faPassword');
|
||||
final data = packet.payload;
|
||||
if (data is Map && data['error'] != null) {
|
||||
throw Exception('Неверный пароль');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProfileData> update2faPassword({
|
||||
required String trackId,
|
||||
required String newPassword,
|
||||
String? hint,
|
||||
}) async {
|
||||
ensureOnline();
|
||||
final validatePacket = await api.sendRequest(Opcode.authValidatePassword, {
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
});
|
||||
checkPacketError(validatePacket, 'update2faPassword: validate');
|
||||
if (validatePacket.payload != null && validatePacket.payload is! Map) {
|
||||
throw Exception('update2faPassword: неожиданный ответ при валидации');
|
||||
}
|
||||
|
||||
if (hint != null) {
|
||||
final hintPacket = await api.sendRequest(Opcode.authValidateHint, {
|
||||
'trackId': trackId,
|
||||
'hint': hint,
|
||||
});
|
||||
checkPacketError(hintPacket, 'update2faPassword: hint');
|
||||
}
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': <int>[1, if (hint != null) 3],
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'update2faPassword',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> commit2faEmailChange(String trackId) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [4],
|
||||
'trackId': trackId,
|
||||
};
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'commit2faEmailChange',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> remove2fa(String trackId) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [5],
|
||||
'trackId': trackId,
|
||||
'remove2fa': true,
|
||||
};
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'remove2fa',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
// Backend module for parsing calls from Komet platform
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'contacts.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/utils/ids.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
|
||||
enum CallStatus { missed, canceled, outgoing, incoming }
|
||||
|
||||
/// Параметры подключения для исходящего звонка (ответ opcode 78).
|
||||
class OutgoingCallParams {
|
||||
final String conversationId;
|
||||
|
||||
/// Полный ws2 URL с уже вшитым токеном (`internalCallerParams.endpoint`).
|
||||
final String endpoint;
|
||||
|
||||
/// Наш id в системе звонков (`internalCallerParams.id.internal`).
|
||||
final int callsUserId;
|
||||
|
||||
final int peerExternalId;
|
||||
@@ -68,70 +65,92 @@ class CallLogEntry {
|
||||
});
|
||||
}
|
||||
|
||||
class _CallerEndpointMissingException implements Exception {
|
||||
final String message;
|
||||
|
||||
const _CallerEndpointMissingException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'Exception: $message';
|
||||
}
|
||||
|
||||
typedef _CallerEndpoint = ({String endpoint, int callsUserId, int? external});
|
||||
|
||||
class CallsModule {
|
||||
final Api _api;
|
||||
|
||||
CallsModule(this._api);
|
||||
|
||||
/// Инициирует исходящий 1:1 звонок (opcode 78).
|
||||
_CallerEndpoint _parseCallerEndpoint(
|
||||
Map payload,
|
||||
String key, {
|
||||
required String context,
|
||||
}) {
|
||||
final raw = payload[key];
|
||||
final parsed = raw is String
|
||||
? jsonDecode(raw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = parsed['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw _CallerEndpointMissingException('$context: no endpoint');
|
||||
}
|
||||
|
||||
final id = parsed['id'];
|
||||
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
|
||||
final external = id is Map ? int.tryParse('${id['external']}') : null;
|
||||
|
||||
return (endpoint: endpoint, callsUserId: callsUserId, external: external);
|
||||
}
|
||||
|
||||
Future<OutgoingCallParams> initiateCall(
|
||||
int calleeId, {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
final conversationId = _uuidV4();
|
||||
final conversationId = uuidV4();
|
||||
|
||||
final response = await _api.sendRequest(Opcode.videoChatStartActive, {
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, {
|
||||
'conversationId': conversationId,
|
||||
'calleeIds': [calleeId],
|
||||
'internalParams': _internalParams(),
|
||||
'isVideo': isVideo,
|
||||
});
|
||||
|
||||
if (!response.isOk || response.payload is! Map) {
|
||||
if (payload == null) {
|
||||
throw Exception('initiateCall: bad response');
|
||||
}
|
||||
final payload = response.payload as Map;
|
||||
|
||||
final icpRaw = payload['internalCallerParams'];
|
||||
final icp = icpRaw is String
|
||||
? jsonDecode(icpRaw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = icp['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw Exception('initiateCall: no endpoint');
|
||||
}
|
||||
|
||||
final id = icp['id'];
|
||||
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
|
||||
final external =
|
||||
(id is Map ? int.tryParse('${id['external']}') : null) ?? calleeId;
|
||||
final parsed = _parseCallerEndpoint(
|
||||
payload,
|
||||
'internalCallerParams',
|
||||
context: 'initiateCall',
|
||||
);
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? conversationId,
|
||||
endpoint: endpoint,
|
||||
callsUserId: callsUserId,
|
||||
peerExternalId: external,
|
||||
endpoint: parsed.endpoint,
|
||||
callsUserId: parsed.callsUserId,
|
||||
peerExternalId: parsed.external ?? calleeId,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
|
||||
String _internalParams() => jsonEncode({
|
||||
'platform': 'ANDROID',
|
||||
'sdkVersion': '0.1.16.4',
|
||||
'clientAppKey': 'CGPGAGLGDIHBABABA',
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'protocolVersion': 5,
|
||||
'onlyAdminCanRecord': false,
|
||||
'waitForAdmin': false,
|
||||
'capabilities': '3c03f',
|
||||
});
|
||||
'platform': 'ANDROID',
|
||||
'sdkVersion': '0.1.16.4',
|
||||
'clientAppKey': 'CGPGAGLGDIHBABABA',
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'protocolVersion': 5,
|
||||
'onlyAdminCanRecord': false,
|
||||
'waitForAdmin': false,
|
||||
'capabilities': '3c03f',
|
||||
});
|
||||
|
||||
Future<CallLinkPreview?> resolveCallLink(String url) async {
|
||||
final response = await _api.sendRequest(Opcode.linkInfo, {'link': url});
|
||||
if (!response.isOk || response.payload is! Map) return null;
|
||||
final payload = await _api.sendRequestMap(Opcode.linkInfo, {'link': url});
|
||||
if (payload == null) return null;
|
||||
|
||||
final vc = (response.payload as Map)['videoConference'];
|
||||
final vc = payload['videoConference'];
|
||||
if (vc is! Map) return null;
|
||||
|
||||
return CallLinkPreview(
|
||||
@@ -146,59 +165,38 @@ class CallsModule {
|
||||
String token, {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
final response = await _api.sendRequest(Opcode.videoChatJoinByLink, {
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, {
|
||||
'joinLink': token,
|
||||
'internalParams': _internalParams(),
|
||||
'isVideo': isVideo,
|
||||
});
|
||||
|
||||
if (!response.isOk || response.payload is! Map) {
|
||||
if (payload == null) {
|
||||
throw Exception('joinByLink: bad response');
|
||||
}
|
||||
final payload = response.payload as Map;
|
||||
|
||||
final ipRaw = payload['internalParams'];
|
||||
final ip = ipRaw is String
|
||||
? jsonDecode(ipRaw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = ip['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw Exception('joinByLink: no endpoint');
|
||||
}
|
||||
|
||||
final id = ip['id'];
|
||||
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
|
||||
final parsed = _parseCallerEndpoint(
|
||||
payload,
|
||||
'internalParams',
|
||||
context: 'joinByLink',
|
||||
);
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? '',
|
||||
endpoint: endpoint,
|
||||
callsUserId: callsUserId,
|
||||
endpoint: parsed.endpoint,
|
||||
callsUserId: parsed.callsUserId,
|
||||
peerExternalId: 0,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
|
||||
static String _uuidV4() {
|
||||
final r = Random();
|
||||
final b = List<int>.generate(16, (_) => r.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String hex(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
final s = List.generate(16, hex).join();
|
||||
return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}'
|
||||
'-${s.substring(16, 20)}-${s.substring(20)}';
|
||||
}
|
||||
|
||||
/// Fetch call history from opcode 79
|
||||
Future<List<CallLogEntry>> fetchHistory(
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
) async {
|
||||
final response = await _api.sendRequest(Opcode.videoChatHistory, {});
|
||||
if (!response.isOk || response.payload is! Map) return [];
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatHistory, {});
|
||||
if (payload == null) return [];
|
||||
|
||||
final payload = response.payload as Map<dynamic, dynamic>;
|
||||
return parseHistoryPayload(
|
||||
payload,
|
||||
accountId,
|
||||
@@ -224,19 +222,19 @@ class CallsModule {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('resolveContacts: $e');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<bool> deleteHistory(List<int> historyIds) async {
|
||||
if (historyIds.isEmpty) return true;
|
||||
final response = await _api.sendRequest(Opcode.videoChatDeleteHistory, {
|
||||
return _api.sendRequestOk(Opcode.videoChatDeleteHistory, {
|
||||
'historyIds': historyIds,
|
||||
});
|
||||
return response.isOk;
|
||||
}
|
||||
|
||||
/// Парсинг истории звонков (opcode 79: videoChatHistory)
|
||||
static Future<List<CallLogEntry>> parseHistoryPayload(
|
||||
Map<dynamic, dynamic> payload,
|
||||
int accountId,
|
||||
@@ -249,8 +247,7 @@ class CallsModule {
|
||||
final recentContacts = await ContactsModule.getContacts(accountId);
|
||||
final contactsMap = {for (final c in recentContacts) c.id: c};
|
||||
|
||||
final parsed =
|
||||
<({int peerId, CallStatus status, int time, String id})>[];
|
||||
final parsed = <({int peerId, CallStatus status, int time, String id})>[];
|
||||
|
||||
for (final item in history.whereType<Map>()) {
|
||||
final msg = item['message'];
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'chat_preview.dart';
|
||||
import 'chats.dart';
|
||||
|
||||
Map<int, Map<dynamic, dynamic>> buildContactsMap(dynamic contacts) {
|
||||
if (contacts is! List) return {};
|
||||
final result = <int, Map<dynamic, dynamic>>{};
|
||||
for (final c in contacts.whereType<Map>()) {
|
||||
final id = c['id'];
|
||||
if (id is int) result[id] = c.cast();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
CachedChat? parseChatRow(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
Map<int, Map<dynamic, dynamic>> contactsMap,
|
||||
Map<dynamic, dynamic> chatsConfig,
|
||||
Map<dynamic, dynamic> presenceMap,
|
||||
Map<int, CachedChat> existing,
|
||||
int cachedAt,
|
||||
) {
|
||||
try {
|
||||
final id = chat['id'];
|
||||
if (id is! int) return null;
|
||||
|
||||
final type = (chat['type'] as String?) ?? 'DIALOG';
|
||||
final otherId = type == 'DIALOG'
|
||||
? _otherParticipantId(chat['participants'], currentUserId)
|
||||
: null;
|
||||
|
||||
final titleIcon = _resolveTitleAndIcon(
|
||||
chat,
|
||||
id,
|
||||
type,
|
||||
otherId,
|
||||
contactsMap,
|
||||
existing,
|
||||
);
|
||||
final lastMessage = _resolveLastMessage(chat['lastMessage']);
|
||||
final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing);
|
||||
final presence = _resolvePresence(type, otherId, presenceMap);
|
||||
final adminsOwner = _resolveAdmins(chat);
|
||||
|
||||
return CachedChat(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
type: type,
|
||||
title: titleIcon.title,
|
||||
iconUrl: titleIcon.iconUrl,
|
||||
lastMsgId: lastMessage.id,
|
||||
lastMsgTime: lastMessage.time,
|
||||
lastMsgText: lastMessage.text,
|
||||
lastMsgElements: lastMessage.elements,
|
||||
lastMsgSenderId: lastMessage.senderId,
|
||||
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
||||
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
||||
cachedAt: cachedAt,
|
||||
favIndex: muteFav.favIndex,
|
||||
dontDisturbUntil: muteFav.dontDisturbUntil,
|
||||
isOnline: presence.isOnline,
|
||||
seenTime: presence.seenTime,
|
||||
participants: parseParticipants(chat['participants']),
|
||||
options: titleIcon.options,
|
||||
owner: adminsOwner.owner,
|
||||
admins: adminsOwner.admins,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при парсинге чата: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
({String? title, String? iconUrl, Set<String> options}) _resolveTitleAndIcon(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int id,
|
||||
String type,
|
||||
int? otherId,
|
||||
Map<int, Map<dynamic, dynamic>> contactsMap,
|
||||
Map<int, CachedChat> existing,
|
||||
) {
|
||||
if (type == 'DIALOG') {
|
||||
final contact = otherId != null ? contactsMap[otherId] : null;
|
||||
if (contact != null) {
|
||||
Set<String> options = const {};
|
||||
final contactOpts = contact['options'];
|
||||
if (contactOpts is List) {
|
||||
options = contactOpts.whereType<String>().toSet();
|
||||
}
|
||||
return (
|
||||
title: _nameFromContact(contact),
|
||||
iconUrl: contact['baseUrl'] as String?,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
return (
|
||||
title: existing[id]?.title,
|
||||
iconUrl: existing[id]?.iconUrl,
|
||||
options: existing[id]?.options ?? const {},
|
||||
);
|
||||
}
|
||||
Set<String> options = const {};
|
||||
final chatOpts = chat['options'];
|
||||
if (chatOpts is Map) {
|
||||
options = {
|
||||
for (final entry in chatOpts.entries)
|
||||
if (entry.value == true && entry.key is String) entry.key as String,
|
||||
};
|
||||
}
|
||||
return (
|
||||
title: chat['title'] as String?,
|
||||
iconUrl: chat['baseIconUrl'] as String?,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
|
||||
({int? id, int? time, String? text, String? elements, int? senderId})
|
||||
_resolveLastMessage(dynamic lastMsg) {
|
||||
if (lastMsg is! Map) {
|
||||
return (id: null, time: null, text: null, elements: null, senderId: null);
|
||||
}
|
||||
return (
|
||||
id: lastMsg['id'] as int?,
|
||||
time: lastMsg['time'] as int?,
|
||||
text: messagePreviewText(lastMsg),
|
||||
elements: messagePreviewElements(lastMsg),
|
||||
senderId: lastMsg['sender'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite(
|
||||
Map<dynamic, dynamic> chatsConfig,
|
||||
int id,
|
||||
Map<int, CachedChat> existing,
|
||||
) {
|
||||
final config = chatsConfig[id.toString()] ?? chatsConfig[id];
|
||||
if (config is Map) {
|
||||
return (
|
||||
favIndex: config['favIndex'] as int?,
|
||||
dontDisturbUntil: (config['dontDisturbUntil'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
final ex = existing[id];
|
||||
if (ex != null) {
|
||||
return (favIndex: ex.favIndex, dontDisturbUntil: ex.dontDisturbUntil);
|
||||
}
|
||||
return (favIndex: null, dontDisturbUntil: 0);
|
||||
}
|
||||
|
||||
({int seenTime, bool isOnline}) _resolvePresence(
|
||||
String type,
|
||||
int? otherId,
|
||||
Map<dynamic, dynamic> presenceMap,
|
||||
) {
|
||||
if (type != 'DIALOG' || otherId == null) {
|
||||
return (seenTime: 0, isOnline: false);
|
||||
}
|
||||
final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId];
|
||||
if (presence is Map) {
|
||||
return (
|
||||
seenTime: (presence['seen'] as int?) ?? 0,
|
||||
isOnline: (presence['status'] as int?) == 1,
|
||||
);
|
||||
}
|
||||
return (seenTime: 0, isOnline: false);
|
||||
}
|
||||
|
||||
({int? owner, Set<int> admins}) _resolveAdmins(Map<dynamic, dynamic> chat) {
|
||||
int? owner;
|
||||
final ownerRaw = chat['owner'];
|
||||
if (ownerRaw is int) {
|
||||
owner = ownerRaw;
|
||||
} else if (ownerRaw is String) {
|
||||
owner = int.tryParse(ownerRaw);
|
||||
}
|
||||
|
||||
Set<int> admins = const {};
|
||||
final adminsRaw = chat['admins'];
|
||||
if (adminsRaw is List) {
|
||||
admins = adminsRaw
|
||||
.map((e) => e is int ? e : int.tryParse(e.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
} else {
|
||||
final adminParticipants = chat['adminParticipants'];
|
||||
if (adminParticipants is Map) {
|
||||
admins = adminParticipants.keys
|
||||
.map((k) => k is int ? k : int.tryParse(k.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
return (owner: owner, admins: admins);
|
||||
}
|
||||
|
||||
int? _otherParticipantId(dynamic participants, int currentUserId) {
|
||||
if (participants is! Map) return null;
|
||||
for (final key in participants.keys) {
|
||||
final id = key is int ? key : int.tryParse(key.toString());
|
||||
if (id != null && id != currentUserId) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _nameFromContact(Map<dynamic, dynamic> contact) {
|
||||
final names = contact['names'];
|
||||
if (names is! List || names.isEmpty) return null;
|
||||
final nameRaw = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
||||
);
|
||||
if (nameRaw is! Map) return null;
|
||||
final name = nameRaw;
|
||||
return name['name'] as String?;
|
||||
}
|
||||
|
||||
List<ChatSearchHit> parseSearchResult(dynamic payload) {
|
||||
final result = (payload as Map?)?['result'];
|
||||
if (result is! List) return const [];
|
||||
final hits = <ChatSearchHit>[];
|
||||
for (final item in result) {
|
||||
if (item is! Map) continue;
|
||||
final chat = item['chat'];
|
||||
if (chat is! Map) continue;
|
||||
final id = chat['id'];
|
||||
if (id is! int) continue;
|
||||
final last = chat['lastMessage'];
|
||||
final link = chat['link'];
|
||||
hits.add(
|
||||
ChatSearchHit(
|
||||
id: id,
|
||||
type: (chat['type'] as String?) ?? 'CHAT',
|
||||
title: chat['title'] as String?,
|
||||
avatarUrl: chat['baseIconUrl'] as String?,
|
||||
subtitle: link is String && link.isNotEmpty
|
||||
? '@$link'
|
||||
: (last is Map ? last['text'] as String? : null),
|
||||
),
|
||||
);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
List<MessageSearchHit> parseMessageResult(dynamic payload) {
|
||||
final result = (payload as Map?)?['result'];
|
||||
if (result is! List) return const [];
|
||||
final hits = <MessageSearchHit>[];
|
||||
for (final item in result) {
|
||||
if (item is! Map) continue;
|
||||
final message = item['message'];
|
||||
if (message is! Map) continue;
|
||||
final chatId = item['chatId'];
|
||||
if (chatId is! int || chatId == 0) continue;
|
||||
hits.add(
|
||||
MessageSearchHit(
|
||||
chatId: chatId,
|
||||
messageId: message['id']?.toString(),
|
||||
text: message['text'] as String?,
|
||||
time: (message['time'] as int?) ?? 0,
|
||||
senderId: (message['sender'] as int?) ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
bool sameChatContent(CachedChat a, CachedChat b) {
|
||||
if (a.title != b.title) return false;
|
||||
if (a.iconUrl != b.iconUrl) return false;
|
||||
if (a.owner != b.owner) return false;
|
||||
if (a.dontDisturbUntil != b.dontDisturbUntil) return false;
|
||||
if (a.favIndex != b.favIndex) return false;
|
||||
if (a.lastMsgId != b.lastMsgId) return false;
|
||||
if (a.lastMsgTime != b.lastMsgTime) return false;
|
||||
if (a.lastMsgText != b.lastMsgText) return false;
|
||||
if (a.lastMsgElements != b.lastMsgElements) return false;
|
||||
if (a.lastMsgSenderId != b.lastMsgSenderId) return false;
|
||||
if (a.unreadCount != b.unreadCount) return false;
|
||||
if (a.lastEventTime != b.lastEventTime) return false;
|
||||
if (a.isOnline != b.isOnline) return false;
|
||||
if (a.seenTime != b.seenTime) return false;
|
||||
if (a.admins.length != b.admins.length) return false;
|
||||
if (!a.admins.containsAll(b.admins)) return false;
|
||||
if (a.options.length != b.options.length) return false;
|
||||
if (!a.options.containsAll(b.options)) return false;
|
||||
if (a.participants.length != b.participants.length) return false;
|
||||
for (final e in a.participants.entries) {
|
||||
if (b.participants[e.key] != e.value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:convert';
|
||||
|
||||
String? attachPreviewLabel(dynamic attaches) {
|
||||
if (attaches is! List || attaches.isEmpty) return null;
|
||||
final first = attaches.first;
|
||||
if (first is! Map) return null;
|
||||
final type = (first['_type'] as String? ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case 'PHOTO':
|
||||
return 'Фото';
|
||||
case 'VIDEO':
|
||||
return 'Видео';
|
||||
case 'AUDIO':
|
||||
return 'Голосовое сообщение';
|
||||
case 'FILE':
|
||||
final name = first['name']?.toString();
|
||||
return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл';
|
||||
case 'STICKER':
|
||||
return 'Стикер';
|
||||
case 'SHARE':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка';
|
||||
case 'POLL':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос';
|
||||
case 'LOCATION':
|
||||
return 'Геопозиция';
|
||||
case 'CONTACT':
|
||||
return 'Контакт';
|
||||
case 'CONTROL':
|
||||
return _controlPreviewLabel(first);
|
||||
case 'INLINE_KEYBOARD':
|
||||
return null;
|
||||
case 'CALL':
|
||||
final video = first['callType']?.toString().toUpperCase() == 'VIDEO';
|
||||
final dur = (first['duration'] as num?)?.toInt() ?? 0;
|
||||
final hangup = first['hangupType']?.toString();
|
||||
final failed =
|
||||
dur == 0 ||
|
||||
hangup == 'CANCELED' ||
|
||||
hangup == 'REJECTED' ||
|
||||
hangup == 'MISSED';
|
||||
if (first['joinLink'] != null) {
|
||||
return video ? 'Групповой видеозвонок' : 'Групповой звонок';
|
||||
}
|
||||
if (failed) {
|
||||
return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок';
|
||||
}
|
||||
return video ? 'Видеозвонок' : 'Звонок';
|
||||
default:
|
||||
return 'Вложение';
|
||||
}
|
||||
}
|
||||
|
||||
String? _controlPreviewLabel(Map c) {
|
||||
final title = c['title']?.toString();
|
||||
if (title != null && title.isNotEmpty) return title;
|
||||
final short = c['shortMessage']?.toString();
|
||||
if (short != null && short.isNotEmpty) return short;
|
||||
switch (c['event']?.toString()) {
|
||||
case 'new':
|
||||
return 'Чат создан';
|
||||
case 'add':
|
||||
case 'joinByLink':
|
||||
return 'Новый участник';
|
||||
case 'leave':
|
||||
return 'Участник вышел';
|
||||
case 'remove':
|
||||
return 'Участник удалён';
|
||||
case 'pin':
|
||||
return 'Закреплённое сообщение';
|
||||
case 'changeTitle':
|
||||
return 'Название чата изменено';
|
||||
case 'changeIcon':
|
||||
return 'Фото чата обновлено';
|
||||
default:
|
||||
return 'Системное сообщение';
|
||||
}
|
||||
}
|
||||
|
||||
String? messagePreviewText(Map msg) {
|
||||
final link = msg['link'];
|
||||
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
|
||||
final original = link['message'];
|
||||
final inner = original is Map ? _bodyPreviewText(original) : null;
|
||||
return inner != null && inner.isNotEmpty
|
||||
? '↪ $inner'
|
||||
: '↪ Пересланное сообщение';
|
||||
}
|
||||
return _bodyPreviewText(msg);
|
||||
}
|
||||
|
||||
String? _bodyPreviewText(Map msg) {
|
||||
final text = msg['text']?.toString();
|
||||
if (text != null && text.isNotEmpty) return text;
|
||||
return attachPreviewLabel(msg['attaches']);
|
||||
}
|
||||
|
||||
String? messagePreviewElements(Map msg) {
|
||||
final text = msg['text'];
|
||||
if (text is! String || text.isEmpty) return null;
|
||||
final elements = msg['elements'];
|
||||
if (elements is List && elements.isNotEmpty) return jsonEncode(elements);
|
||||
return null;
|
||||
}
|
||||
+276
-542
File diff suppressed because it is too large
Load Diff
@@ -82,23 +82,27 @@ class CloudStorageModule {
|
||||
}
|
||||
|
||||
static Future<void> _configurePrivacy(Api api, int chatId) async {
|
||||
await Future.wait([
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_ADD_MEMBER': true}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ALL_CAN_PIN_MESSAGE': false}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_CALL': true}),
|
||||
]);
|
||||
await chats.setChatOptions(
|
||||
api,
|
||||
chatId: chatId,
|
||||
options: {
|
||||
'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true,
|
||||
'ONLY_ADMIN_CAN_ADD_MEMBER': true,
|
||||
'ALL_CAN_PIN_MESSAGE': false,
|
||||
'ONLY_ADMIN_CAN_CALL': true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CachedChat?> setupEnv(Api api) async {
|
||||
final temp = await ChatsModule.createGroupChat(
|
||||
final temp = await chats.createGroupChat(
|
||||
api,
|
||||
title: _tempName,
|
||||
userIds: [],
|
||||
);
|
||||
if (temp == null) return null;
|
||||
final name = '$_prefix${_computeSpecialNumber(temp.id)}';
|
||||
final ok = await ChatsModule.setChatTitle(api, chatId: temp.id, title: name);
|
||||
final ok = await chats.setChatTitle(api, chatId: temp.id, title: name);
|
||||
if (!ok) return null;
|
||||
await _configurePrivacy(api, temp.id);
|
||||
return temp;
|
||||
@@ -107,12 +111,34 @@ class CloudStorageModule {
|
||||
// Turns an orphan "Облачное хранилище" group into a valid env group
|
||||
static Future<CachedChat?> repairOrphan(Api api, CachedChat orphan) async {
|
||||
final name = '$_prefix${_computeSpecialNumber(orphan.id)}';
|
||||
final ok = await ChatsModule.setChatTitle(api, chatId: orphan.id, title: name);
|
||||
final ok = await chats.setChatTitle(api, chatId: orphan.id, title: name);
|
||||
if (!ok) return null;
|
||||
await _configurePrivacy(api, orphan.id);
|
||||
return orphan;
|
||||
}
|
||||
|
||||
static Iterable<CloudFile> _cloudFilesFrom(
|
||||
Iterable<CachedMessage> msgs,
|
||||
int chatId,
|
||||
int accountId,
|
||||
) sync* {
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
yield CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CloudFile>> fetchFiles(
|
||||
MessagesModule messages,
|
||||
int accountId,
|
||||
@@ -120,23 +146,7 @@ class CloudStorageModule {
|
||||
int count = 200,
|
||||
}) async {
|
||||
final msgs = await messages.fetchHistory(accountId, chatId, count: count);
|
||||
final files = <CloudFile>[];
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
files.add(CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
return _cloudFilesFrom(msgs, chatId, accountId).toList();
|
||||
}
|
||||
|
||||
// Fetches only the last few messages to find a newly uploaded file — avoids full 200-msg reload
|
||||
@@ -147,21 +157,9 @@ class CloudStorageModule {
|
||||
int? expectedFileId,
|
||||
}) async {
|
||||
final msgs = await messages.fetchHistory(accountId, chatId, count: 5);
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
if (expectedFileId == null || a.fileId == expectedFileId) {
|
||||
return CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final file in _cloudFilesFrom(msgs, chatId, accountId)) {
|
||||
if (expectedFileId == null || file.fileId == expectedFileId) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -11,14 +11,15 @@ class ComplaintReason {
|
||||
class ComplaintsModule {
|
||||
static Map<int, List<ComplaintReason>>? _cache;
|
||||
|
||||
static void clear() => _cache = null;
|
||||
|
||||
static Future<Map<int, List<ComplaintReason>>> fetchReasons(Api api) async {
|
||||
final cached = _cache;
|
||||
if (cached != null) return cached;
|
||||
|
||||
final response = await api.sendRequest(
|
||||
Opcode.complainReasonsGet,
|
||||
{'complainSync': 0},
|
||||
);
|
||||
final response = await api.sendRequest(Opcode.complainReasonsGet, {
|
||||
'complainSync': 0,
|
||||
});
|
||||
if (!response.isOk) return cached ?? const {};
|
||||
|
||||
final payload = response.payload;
|
||||
|
||||
@@ -56,8 +56,9 @@ class DigitalIdModule {
|
||||
final hashIndex = url.indexOf('#');
|
||||
if (hashIndex < 0) return null;
|
||||
final fragment = url.substring(hashIndex + 1);
|
||||
final match = RegExp(r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)')
|
||||
.firstMatch(fragment);
|
||||
final match = RegExp(
|
||||
r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)',
|
||||
).firstMatch(fragment);
|
||||
final raw = match?.group(1);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return Uri.decodeComponent(raw);
|
||||
@@ -84,8 +85,7 @@ class DigitalIdModule {
|
||||
Future<String> _generateDeviceId() async {
|
||||
final rnd = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||
final hex =
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
@@ -162,7 +162,8 @@ class DigitalIdModule {
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map) {
|
||||
final rawCode = decoded['code'] ?? decoded['error'] ?? decoded['status'];
|
||||
final rawCode =
|
||||
decoded['code'] ?? decoded['error'] ?? decoded['status'];
|
||||
if (rawCode is String && rawCode.isNotEmpty) code = rawCode;
|
||||
final rawMessage = decoded['message'] ?? decoded['error_description'];
|
||||
if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage;
|
||||
@@ -191,26 +192,26 @@ class DigitalIdModule {
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/create-biometry-token',
|
||||
body: {
|
||||
'device_id': deviceId,
|
||||
'photo_hash': ?photoHash,
|
||||
},
|
||||
body: {'device_id': deviceId, 'photo_hash': ?photoHash},
|
||||
);
|
||||
return _unwrapData(decoded)['token'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<String> refreshUserDocs(String token) async {
|
||||
final decoded =
|
||||
await _send('POST', '/v3/digital-id/refresh-user-docs', body: {
|
||||
'token': token,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/refresh-user-docs',
|
||||
body: {'token': token},
|
||||
);
|
||||
return _unwrapData(decoded)['state'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<DigitalIdUserDocs?> getUserDocs(String state) async {
|
||||
final decoded = await _send('POST', '/v2/digital-id/get-user-docs', body: {
|
||||
'state': state,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v2/digital-id/get-user-docs',
|
||||
body: {'state': state},
|
||||
);
|
||||
if (decoded is Map && decoded['status'] == 'done') {
|
||||
final data = decoded['data'];
|
||||
if (data is Map) return DigitalIdUserDocs.fromMap(data);
|
||||
@@ -227,19 +228,22 @@ class DigitalIdModule {
|
||||
required String deviceId,
|
||||
String? photoHash,
|
||||
}) async {
|
||||
final decoded = await _send('POST', '/digital-id-verify-photo', body: {
|
||||
'device_id': deviceId,
|
||||
'photo_hash': ?photoHash,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/digital-id-verify-photo',
|
||||
body: {'device_id': deviceId, 'photo_hash': ?photoHash},
|
||||
);
|
||||
final status = decoded is Map ? decoded['status'] as String? : null;
|
||||
return DigitalIdVerification.fromValue(status);
|
||||
}
|
||||
|
||||
Future<bool> shadowMode(String deviceId) async {
|
||||
try {
|
||||
final decoded = await _send('POST', '/v3/digital-id/shadow-mode', body: {
|
||||
'device_id': deviceId,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/shadow-mode',
|
||||
body: {'device_id': deviceId},
|
||||
);
|
||||
return _unwrapData(decoded)['shadow_mode'] == true;
|
||||
} on DigitalIdException catch (e) {
|
||||
if (e.code == 'HTTP_404') return false;
|
||||
@@ -252,9 +256,11 @@ class DigitalIdModule {
|
||||
}
|
||||
|
||||
Future<DigitalIdUniversalQr> userQr(String token) async {
|
||||
final decoded = await _send('POST', '/v3/digital-id/user-qr', body: {
|
||||
'token': token,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/user-qr',
|
||||
body: {'token': token},
|
||||
);
|
||||
return DigitalIdUniversalQr.fromMap(_unwrapData(decoded));
|
||||
}
|
||||
|
||||
@@ -264,12 +270,16 @@ class DigitalIdModule {
|
||||
required DigitalIdQrType qrType,
|
||||
String? kidAct,
|
||||
}) async {
|
||||
final decoded = await _send('POST', '/v3/digital-id/generate-qr', body: {
|
||||
'photo': photo,
|
||||
'token': token,
|
||||
'qr_type': qrType.code,
|
||||
'kid_act': ?kidAct,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/generate-qr',
|
||||
body: {
|
||||
'photo': photo,
|
||||
'token': token,
|
||||
'qr_type': qrType.code,
|
||||
'kid_act': ?kidAct,
|
||||
},
|
||||
);
|
||||
return DigitalIdQr.fromMap(_unwrapData(decoded));
|
||||
}
|
||||
|
||||
@@ -277,14 +287,9 @@ class DigitalIdModule {
|
||||
String? passStatus,
|
||||
String? inn,
|
||||
}) async {
|
||||
final query = <String, String>{
|
||||
'pass_status': ?passStatus,
|
||||
'inn': ?inn,
|
||||
};
|
||||
final suffix =
|
||||
query.isEmpty ? '' : '?${Uri(queryParameters: query).query}';
|
||||
final decoded =
|
||||
await _send('GET', '/v2/digital-id/get-cards-list$suffix');
|
||||
final query = <String, String>{'pass_status': ?passStatus, 'inn': ?inn};
|
||||
final suffix = query.isEmpty ? '' : '?${Uri(queryParameters: query).query}';
|
||||
final decoded = await _send('GET', '/v2/digital-id/get-cards-list$suffix');
|
||||
final cards = _unwrapData(decoded)['acms_cards'];
|
||||
if (cards is! List) return const [];
|
||||
return cards
|
||||
@@ -294,17 +299,19 @@ class DigitalIdModule {
|
||||
}
|
||||
|
||||
Future<void> activateAcms({required String id, required String inn}) async {
|
||||
await _send('POST', '/v2/digital-id/activate-acms', body: {
|
||||
'id': id,
|
||||
'inn': inn,
|
||||
'pass_status': 'active',
|
||||
});
|
||||
await _send(
|
||||
'POST',
|
||||
'/v2/digital-id/activate-acms',
|
||||
body: {'id': id, 'inn': inn, 'pass_status': 'active'},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createLiteProfile(String deviceId) async {
|
||||
await _send('POST', '/v2/digital-id/create-lite-profile', body: {
|
||||
'device_id': deviceId,
|
||||
});
|
||||
await _send(
|
||||
'POST',
|
||||
'/v2/digital-id/create-lite-profile',
|
||||
body: {'device_id': deviceId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteProfile() async {
|
||||
|
||||
@@ -42,6 +42,9 @@ class UploadError extends UploadEvent {
|
||||
}
|
||||
|
||||
class FileUploader {
|
||||
static const String _userAgentHeader =
|
||||
'OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)';
|
||||
|
||||
final Api api;
|
||||
final MessagesModule messages;
|
||||
|
||||
@@ -87,36 +90,24 @@ class FileUploader {
|
||||
}());
|
||||
|
||||
final uri = Uri.parse(info.url);
|
||||
socket = await _openSocket(uri);
|
||||
if (cancelled) return;
|
||||
|
||||
_writeHeaders(socket!, uri, filename, totalSize);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (stopwatch.elapsed >= progressThrottle) {
|
||||
ctrl.add(UploadProgress(sent: sent, total: totalSize));
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket!.addStream(body);
|
||||
await socket!.flush();
|
||||
if (cancelled) return;
|
||||
ctrl.add(UploadProgress(sent: totalSize, total: totalSize));
|
||||
|
||||
final statusCode = await _readResponse(
|
||||
socket!,
|
||||
final result = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildUploadHeaders(uri, filename, totalSize),
|
||||
bodyStream: file.openRead(),
|
||||
progressTotal: totalSize,
|
||||
onProgress: (sent, total) {
|
||||
if (!cancelled) ctrl.add(UploadProgress(sent: sent, total: total));
|
||||
},
|
||||
progressThrottle: progressThrottle,
|
||||
autoForceAfter: autoForceAfter,
|
||||
overallTimeout: overallTimeout,
|
||||
timeout: overallTimeout,
|
||||
onSocketReady: (s) => socket = s,
|
||||
shouldAbort: () => cancelled,
|
||||
);
|
||||
try {
|
||||
socket!.destroy();
|
||||
} catch (_) {}
|
||||
if (cancelled) return;
|
||||
|
||||
final statusCode = result?.$1 ?? 0;
|
||||
if (statusCode != 200 && statusCode != 0) {
|
||||
ctrl.add(UploadError('http_$statusCode'));
|
||||
return;
|
||||
@@ -134,13 +125,15 @@ class FileUploader {
|
||||
return;
|
||||
}
|
||||
|
||||
ctrl.add(UploadDone(
|
||||
fileId: info.fileId,
|
||||
token: info.token,
|
||||
url: info.url,
|
||||
filename: filename,
|
||||
size: totalSize,
|
||||
));
|
||||
ctrl.add(
|
||||
UploadDone(
|
||||
fileId: info.fileId,
|
||||
token: info.token,
|
||||
url: info.url,
|
||||
filename: filename,
|
||||
size: totalSize,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!cancelled) ctrl.add(UploadError(e.toString()));
|
||||
} finally {
|
||||
@@ -155,11 +148,6 @@ class FileUploader {
|
||||
return ctrl.stream;
|
||||
}
|
||||
|
||||
/// Загружает медиа (Ogg/Opus аудио или MP4 видеосообщение) на CDN-URL,
|
||||
/// полученный из [MessagesModule.requestAudioUploadUrl] /
|
||||
/// [MessagesModule.requestVideoNoteUploadUrl]. Одиночный POST всего файла
|
||||
/// (`octet-stream`, `Content-Range` на весь объём, `filename=<число>`).
|
||||
/// Токен уже известен, поэтому возвращается только признак успеха.
|
||||
Future<bool> uploadMediaFile(
|
||||
Uri uri,
|
||||
File file, {
|
||||
@@ -167,43 +155,30 @@ class FileUploader {
|
||||
Duration overallTimeout = const Duration(minutes: 5),
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
final total = await file.length();
|
||||
if (total <= 0) return false;
|
||||
final filename =
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
final filename = _syntheticFilename();
|
||||
|
||||
socket = await _openSocket(uri);
|
||||
_writeHeaders(
|
||||
socket,
|
||||
final result = await _sendHttpRequest(
|
||||
uri,
|
||||
filename,
|
||||
total,
|
||||
contentType: 'application/octet-stream',
|
||||
connection: 'close',
|
||||
method: 'POST',
|
||||
headers: _buildUploadHeaders(
|
||||
uri,
|
||||
filename,
|
||||
total,
|
||||
contentType: 'application/octet-stream',
|
||||
connection: 'close',
|
||||
),
|
||||
bodyStream: file.openRead(),
|
||||
progressTotal: total,
|
||||
onProgress: onProgress,
|
||||
progressThrottle: progressThrottle,
|
||||
timeout: overallTimeout,
|
||||
);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (onProgress != null && stopwatch.elapsed >= progressThrottle) {
|
||||
onProgress(sent, total);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket.addStream(body);
|
||||
await socket.flush();
|
||||
onProgress?.call(total, total);
|
||||
|
||||
final response = await _readFullResponse(socket, timeout: overallTimeout);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
final statusCode = response?.$1 ?? 0;
|
||||
final respBody = response?.$2 ?? '';
|
||||
final statusCode = result?.$1 ?? 0;
|
||||
final respBody = result?.$2 ?? '';
|
||||
logger.w(
|
||||
'uploadMediaFile: status=$statusCode total=$total '
|
||||
'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}',
|
||||
@@ -213,9 +188,6 @@ class FileUploader {
|
||||
return statusCode == 200 && !hasError;
|
||||
} catch (e) {
|
||||
logger.w('uploadMediaFile: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -228,39 +200,49 @@ class FileUploader {
|
||||
if (uri.scheme != 'https') return base;
|
||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||
if (allowInsecure) {
|
||||
logger.w('TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM');
|
||||
return SecureSocket.secure(base, host: uri.host, onBadCertificate: (_) => true);
|
||||
logger.w(
|
||||
'TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM',
|
||||
);
|
||||
return SecureSocket.secure(
|
||||
base,
|
||||
host: uri.host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
}
|
||||
return SecureSocket.secure(base, host: uri.host);
|
||||
}
|
||||
|
||||
void _writeHeaders(
|
||||
Socket socket,
|
||||
String _syntheticFilename() =>
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
|
||||
String _multipartBoundary() =>
|
||||
'----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
|
||||
Map<String, String> _buildUploadHeaders(
|
||||
Uri uri,
|
||||
String filename,
|
||||
int total, {
|
||||
String contentType = 'application/x-binary; charset=x-user-defined',
|
||||
String connection = 'keep-alive',
|
||||
}) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: $contentType\r\n')
|
||||
..write('Content-Disposition: attachment; filename=$filename\r\n')
|
||||
..write('Connection: $connection\r\n')
|
||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||
..write('Content-Range: bytes 0-${total - 1}/$total\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
return {
|
||||
'Host': uri.host,
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': 'attachment; filename=$filename',
|
||||
'Connection': connection,
|
||||
'User-Agent': Uri.encodeComponent(_userAgentHeader),
|
||||
'Content-Range': 'bytes 0-${total - 1}/$total',
|
||||
'Content-Length': '$total',
|
||||
};
|
||||
}
|
||||
|
||||
Future<String?> uploadImage(Uri uri, Uint8List bytes, {String filename = 'avatar.jpg'}) async {
|
||||
Socket? socket;
|
||||
Future<String?> uploadImage(
|
||||
Uri uri,
|
||||
Uint8List bytes, {
|
||||
String filename = 'avatar.jpg',
|
||||
}) async {
|
||||
try {
|
||||
socket = await _openSocket(uri);
|
||||
final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
final boundary = _multipartBoundary();
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
@@ -268,43 +250,40 @@ class FileUploader {
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
uri,
|
||||
preamble.length + bytes.length + epilogue.length,
|
||||
boundary: boundary,
|
||||
);
|
||||
socket.add(preamble);
|
||||
socket.add(bytes);
|
||||
socket.add(epilogue);
|
||||
await socket.flush();
|
||||
|
||||
final response = await _readFullResponse(
|
||||
socket,
|
||||
final response = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildMultipartHeaders(
|
||||
uri,
|
||||
preamble.length + bytes.length + epilogue.length,
|
||||
boundary: boundary,
|
||||
),
|
||||
prefixBytes: preamble,
|
||||
bodyStream: Stream.value(bytes),
|
||||
suffixBytes: epilogue,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
final (status, body) = response;
|
||||
if (status != 200) {
|
||||
logger.w('uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
logger.w(
|
||||
'uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final token = _parsePhotoToken(body);
|
||||
if (token == null) {
|
||||
logger.w('uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
logger.w(
|
||||
'uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}',
|
||||
);
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadImage: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -316,12 +295,9 @@ class FileUploader {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
final fileLength = await file.length();
|
||||
socket = await _openSocket(uri);
|
||||
final boundary =
|
||||
'----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
final boundary = _multipartBoundary();
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
@@ -329,36 +305,23 @@ class FileUploader {
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
|
||||
final response = await _sendHttpRequest(
|
||||
uri,
|
||||
preamble.length + fileLength + epilogue.length,
|
||||
boundary: boundary,
|
||||
);
|
||||
socket.add(preamble);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (onProgress != null && stopwatch.elapsed >= progressThrottle) {
|
||||
onProgress(sent, fileLength);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket.addStream(body);
|
||||
socket.add(epilogue);
|
||||
await socket.flush();
|
||||
onProgress?.call(fileLength, fileLength);
|
||||
|
||||
final response = await _readFullResponse(
|
||||
socket,
|
||||
method: 'POST',
|
||||
headers: _buildMultipartHeaders(
|
||||
uri,
|
||||
preamble.length + fileLength + epilogue.length,
|
||||
boundary: boundary,
|
||||
),
|
||||
prefixBytes: preamble,
|
||||
bodyStream: file.openRead(),
|
||||
suffixBytes: epilogue,
|
||||
progressTotal: fileLength,
|
||||
onProgress: onProgress,
|
||||
progressThrottle: progressThrottle,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) return null;
|
||||
final (status, responseBody) = response;
|
||||
@@ -369,20 +332,10 @@ class FileUploader {
|
||||
return _parsePhotoToken(responseBody);
|
||||
} catch (e) {
|
||||
logger.w('uploadPhoto: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает видео на CDN-URL (vu.okcdn.ru/upload.do), полученный из
|
||||
/// [MessagesModule.requestVideoUploadUrl], по протоколу OK с докачкой:
|
||||
/// сначала GET-хендшейк (возвращает уже загруженный оффсет), затем
|
||||
/// параллельная отправка чанков по [chunkSize] байт через `Content-Range`
|
||||
/// ([concurrency] одновременных соединений, режим `X-Uploading-Mode:
|
||||
/// parallel`). Токен уже известен, поэтому возвращается только признак
|
||||
/// успеха.
|
||||
Future<bool> uploadVideoFile(
|
||||
Uri uri,
|
||||
File file, {
|
||||
@@ -394,8 +347,7 @@ class FileUploader {
|
||||
final total = await file.length();
|
||||
if (total <= 0) return false;
|
||||
|
||||
final fileName =
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
final fileName = _syntheticFilename();
|
||||
|
||||
final handshake = await _okCdnRequest(
|
||||
uri,
|
||||
@@ -470,56 +422,140 @@ class FileUploader {
|
||||
String? contentRange,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
socket = await _openSocket(uri);
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('$method $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
|
||||
..write('Content-Disposition: attachment; fileName="$fileName"\r\n');
|
||||
if (contentRange != null) {
|
||||
headers.write('Content-Range: $contentRange\r\n');
|
||||
}
|
||||
headers
|
||||
..write('Content-Length: ${body?.length ?? 0}\r\n')
|
||||
..write('X-Uploading-Mode: parallel\r\n')
|
||||
..write('Connection: close\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
if (body != null && body.isNotEmpty) socket.add(body);
|
||||
await socket.flush();
|
||||
|
||||
final response = await _readFullResponse(socket, timeout: timeout);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
return response;
|
||||
final headers = {
|
||||
'Host': uri.host,
|
||||
'Content-Type': 'application/x-binary; charset=x-user-defined',
|
||||
'Content-Disposition': 'attachment; fileName="$fileName"',
|
||||
'Content-Range': ?contentRange,
|
||||
'Content-Length': '${body?.length ?? 0}',
|
||||
'X-Uploading-Mode': 'parallel',
|
||||
'Connection': 'close',
|
||||
};
|
||||
return await _sendHttpRequest(
|
||||
uri,
|
||||
method: method,
|
||||
headers: headers,
|
||||
prefixBytes: body,
|
||||
timeout: timeout,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('_okCdnRequest($method): $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
|
||||
Map<String, String> _buildMultipartHeaders(
|
||||
Uri uri,
|
||||
int total, {
|
||||
required String boundary,
|
||||
}) {
|
||||
return {
|
||||
'Host': uri.host,
|
||||
'Content-Type': 'multipart/form-data; boundary=$boundary',
|
||||
'Content-Length': '$total',
|
||||
'Connection': 'keep-alive',
|
||||
'User-Agent': Uri.encodeComponent(_userAgentHeader),
|
||||
};
|
||||
}
|
||||
|
||||
Future<(int, String)?> _sendHttpRequest(
|
||||
Uri uri, {
|
||||
required String method,
|
||||
required Map<String, String> headers,
|
||||
List<int>? prefixBytes,
|
||||
Stream<List<int>>? bodyStream,
|
||||
List<int>? suffixBytes,
|
||||
int? progressTotal,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
Duration? autoForceAfter,
|
||||
required Duration timeout,
|
||||
void Function(Socket socket)? onSocketReady,
|
||||
bool Function()? shouldAbort,
|
||||
}) async {
|
||||
final socket = await _openSocket(uri);
|
||||
onSocketReady?.call(socket);
|
||||
try {
|
||||
if (shouldAbort?.call() ?? false) return null;
|
||||
|
||||
_writeRequestHeaders(socket, uri, method, headers);
|
||||
if (prefixBytes != null && prefixBytes.isNotEmpty) {
|
||||
socket.add(prefixBytes);
|
||||
}
|
||||
if (bodyStream != null) {
|
||||
final stream = (onProgress != null && progressTotal != null)
|
||||
? _withProgress(
|
||||
bodyStream,
|
||||
progressTotal,
|
||||
onProgress,
|
||||
throttle: progressThrottle,
|
||||
)
|
||||
: bodyStream;
|
||||
await socket.addStream(stream);
|
||||
}
|
||||
if (suffixBytes != null && suffixBytes.isNotEmpty) {
|
||||
socket.add(suffixBytes);
|
||||
}
|
||||
await socket.flush();
|
||||
if (onProgress != null && progressTotal != null) {
|
||||
onProgress(progressTotal, progressTotal);
|
||||
}
|
||||
if (shouldAbort?.call() ?? false) return null;
|
||||
|
||||
if (autoForceAfter != null) {
|
||||
final status = await _readResponse(
|
||||
socket,
|
||||
autoForceAfter: autoForceAfter,
|
||||
overallTimeout: timeout,
|
||||
);
|
||||
return (status, '');
|
||||
}
|
||||
return await _readFullResponse(socket, timeout: timeout);
|
||||
} finally {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _writeRequestHeaders(
|
||||
Socket socket,
|
||||
Uri uri,
|
||||
String method,
|
||||
Map<String, String> headers,
|
||||
) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: multipart/form-data; boundary=$boundary\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('Connection: keep-alive\r\n')
|
||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
final buffer = StringBuffer()..write('$method $path HTTP/1.1\r\n');
|
||||
for (final entry in headers.entries) {
|
||||
buffer.write('${entry.key}: ${entry.value}\r\n');
|
||||
}
|
||||
buffer.write('\r\n');
|
||||
socket.add(utf8.encode(buffer.toString()));
|
||||
}
|
||||
|
||||
Stream<List<int>> _withProgress(
|
||||
Stream<List<int>> src,
|
||||
int total,
|
||||
void Function(int sent, int total) onProgress, {
|
||||
Duration throttle = const Duration(milliseconds: 16),
|
||||
}) {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
return src.map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (stopwatch.elapsed >= throttle) {
|
||||
onProgress(sent, total);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
}
|
||||
|
||||
String _contentTypeForFilename(String filename) {
|
||||
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
|
||||
final ext = filename.contains('.')
|
||||
? filename.split('.').last.toLowerCase()
|
||||
: '';
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
@@ -557,13 +593,17 @@ class FileUploader {
|
||||
(int, String)? tryParse({required bool atClose}) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final headerStr = utf8.decode(
|
||||
bytes.sublist(0, headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
final lines = headerStr.split('\r\n');
|
||||
final parts = lines.first.split(' ');
|
||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||
final headerLines = lines.skip(1);
|
||||
final chunked = headerLines.any(
|
||||
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
(l) =>
|
||||
l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
l.toLowerCase().contains('chunked'),
|
||||
);
|
||||
int? contentLength;
|
||||
@@ -572,12 +612,17 @@ class FileUploader {
|
||||
contentLength = int.tryParse(l.split(':').last.trim());
|
||||
}
|
||||
}
|
||||
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||
final rawBody = utf8.decode(
|
||||
bytes.sublist(headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
if (chunked) {
|
||||
if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
|
||||
return (status, _decodeChunked(rawBody));
|
||||
}
|
||||
if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) {
|
||||
if (contentLength != null &&
|
||||
!atClose &&
|
||||
bytes.length - headerEnd < contentLength) {
|
||||
return null;
|
||||
}
|
||||
return (status, rawBody);
|
||||
@@ -596,7 +641,9 @@ class FileUploader {
|
||||
onDone: () {
|
||||
final parsed = tryParse(atClose: true);
|
||||
if (parsed == null) {
|
||||
logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)');
|
||||
logger.w(
|
||||
'uploadImage: connection closed without HTTP response (${bytes.length} bytes)',
|
||||
);
|
||||
}
|
||||
finishWith(parsed);
|
||||
},
|
||||
@@ -697,7 +744,10 @@ class FileUploader {
|
||||
},
|
||||
);
|
||||
|
||||
overall = Timer(overallTimeout, () => fail(TimeoutException('Тайм-аут загрузки')));
|
||||
overall = Timer(
|
||||
overallTimeout,
|
||||
() => fail(TimeoutException('Тайм-аут загрузки')),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
@@ -705,7 +755,10 @@ class FileUploader {
|
||||
int? _parseHttpStatus(List<int> bytes) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final headerStr = utf8.decode(
|
||||
bytes.sublist(0, headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
final statusLine = headerStr.split('\r\n').first;
|
||||
final parts = statusLine.split(' ');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
@@ -25,10 +25,7 @@ class FoldersModule {
|
||||
static bool isAllChatsFolder(ChatFolder f) {
|
||||
if (f.id == 'all.chat.folder') return true;
|
||||
final t = f.title.trim().toLowerCase();
|
||||
return t == 'все' ||
|
||||
t == 'все чаты' ||
|
||||
t == 'all' ||
|
||||
t == 'all chats';
|
||||
return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats';
|
||||
}
|
||||
|
||||
static String? preferredInitialFolderId(List<ChatFolder> folders) {
|
||||
@@ -88,20 +85,42 @@ class FoldersModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<ChatFolder> _parseFolderList(
|
||||
List<dynamic> json, {
|
||||
bool lenient = true,
|
||||
}) {
|
||||
if (lenient) {
|
||||
return json
|
||||
.map((e) {
|
||||
try {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
}
|
||||
return json.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static Future<List<ChatFolder>> loadFolders(int accountId) async {
|
||||
final raw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final folders = (map['folders'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
})
|
||||
.toList() ??
|
||||
[];
|
||||
final foldersJson = map['folders'] as List<dynamic>?;
|
||||
final folders = foldersJson == null
|
||||
? <ChatFolder>[]
|
||||
: _parseFolderList(foldersJson, lenient: false);
|
||||
final order = map['foldersOrder'] as List<dynamic>?;
|
||||
sortFoldersInPlace(folders, order);
|
||||
return folders;
|
||||
@@ -135,19 +154,7 @@ class FoldersModule {
|
||||
|
||||
List<ChatFolder> folders;
|
||||
if (foldersJson != null) {
|
||||
folders = foldersJson
|
||||
.map((json) {
|
||||
try {
|
||||
final m = json is Map<String, dynamic>
|
||||
? json
|
||||
: Map<String, dynamic>.from(json as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
folders = _parseFolderList(foldersJson);
|
||||
} else {
|
||||
folders = await loadFolders(accountId);
|
||||
}
|
||||
@@ -164,19 +171,7 @@ class FoldersModule {
|
||||
final foldersJson = chatFolders['FOLDERS'] as List<dynamic>?;
|
||||
if (foldersJson == null) return;
|
||||
final order = chatFolders['foldersOrder'] as List<dynamic>?;
|
||||
final folders = foldersJson
|
||||
.map((json) {
|
||||
try {
|
||||
final m = json is Map<String, dynamic>
|
||||
? json
|
||||
: Map<String, dynamic>.from(json as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
final folders = _parseFolderList(foldersJson);
|
||||
sortFoldersInPlace(folders, order);
|
||||
await _persist(accountId, folders, order);
|
||||
await markFoldersListReady(accountId);
|
||||
@@ -196,9 +191,7 @@ class FoldersModule {
|
||||
'filters': folder.filters,
|
||||
'options': folder.options ?? const [],
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
throwIfPacketError(packet);
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return null;
|
||||
final folderJson = data['folder'];
|
||||
@@ -213,15 +206,10 @@ class FoldersModule {
|
||||
final snapshot = (currentRaw != null && currentRaw.isNotEmpty)
|
||||
? jsonDecode(currentRaw) as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
final existing = (snapshot['folders'] as List?)
|
||||
?.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
})
|
||||
.toList() ??
|
||||
<ChatFolder>[];
|
||||
final existingRaw = snapshot['folders'] as List<dynamic>?;
|
||||
final existing = existingRaw == null
|
||||
? <ChatFolder>[]
|
||||
: _parseFolderList(existingRaw, lenient: false);
|
||||
final idx = existing.indexWhere((f) => f.id == updated.id);
|
||||
if (idx >= 0) {
|
||||
existing[idx] = updated;
|
||||
@@ -238,9 +226,7 @@ class FoldersModule {
|
||||
final packet = await api.sendRequest(Opcode.foldersGet, {
|
||||
'folderSync': 0,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
throwIfPacketError(packet);
|
||||
final data = packet.payload;
|
||||
if (data is Map) {
|
||||
await applyPayload(accountId, data.cast<dynamic, dynamic>());
|
||||
|
||||
+148
-295
@@ -10,7 +10,7 @@ import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
import 'chats.dart' show chats;
|
||||
|
||||
class ContactCache {
|
||||
static final Map<int, String> _nameCache = {};
|
||||
@@ -337,6 +337,10 @@ class ReplyInfo {
|
||||
return '';
|
||||
case AttachmentType.inlineKeyboard:
|
||||
return '';
|
||||
case AttachmentType.forward:
|
||||
return 'Переслано';
|
||||
case AttachmentType.unknown:
|
||||
return 'Вложение';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -434,6 +438,28 @@ class CachedMessage {
|
||||
return list;
|
||||
}
|
||||
|
||||
static (List<MessageAttachment>?, bool) parseAttachments(
|
||||
Map<String, dynamic> map,
|
||||
) {
|
||||
List<MessageAttachment>? attachments;
|
||||
final link = map['link'];
|
||||
final linkType = link is Map ? link['type'] as String? : null;
|
||||
if (linkType == 'FORWARD') {
|
||||
attachments = [ForwardedMessageAttachment.fromMap(map)];
|
||||
} else {
|
||||
final attaches = map['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
final isControl =
|
||||
attachments?.any((a) => a.type == AttachmentType.control) ?? false;
|
||||
return (attachments, isControl);
|
||||
}
|
||||
|
||||
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
|
||||
Map<String, dynamic>? payload;
|
||||
final payloadRaw = row['payload'];
|
||||
@@ -444,22 +470,11 @@ class CachedMessage {
|
||||
}
|
||||
|
||||
List<MessageAttachment>? attachments;
|
||||
bool isControl = false;
|
||||
if (payload != null) {
|
||||
final linkType = payload['link']?['type'] as String?;
|
||||
if (linkType == 'FORWARD') {
|
||||
attachments = [ForwardedMessageAttachment.fromMap(payload)];
|
||||
} else {
|
||||
final attaches = payload['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.map(
|
||||
(a) => MessageAttachment.fromMap(
|
||||
Map<String, dynamic>.from(a as Map),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
final parsed = parseAttachments(payload);
|
||||
attachments = parsed.$1;
|
||||
isControl = parsed.$2;
|
||||
}
|
||||
|
||||
return CachedMessage(
|
||||
@@ -480,8 +495,7 @@ class CachedMessage {
|
||||
status: row['status']?.toString(),
|
||||
payload: payload,
|
||||
attachments: attachments,
|
||||
isControl:
|
||||
attachments?.any((a) => a.type == AttachmentType.control) ?? false,
|
||||
isControl: isControl,
|
||||
deleted: row['deleted'] is int
|
||||
? row['deleted'] == 1
|
||||
: row['deleted']?.toString() == '1',
|
||||
@@ -503,7 +517,8 @@ class CachedMessage {
|
||||
|
||||
ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload);
|
||||
|
||||
List<FormatRange> get formatRanges => parseFormatElements(payload?['elements']);
|
||||
List<FormatRange> get formatRanges =>
|
||||
parseFormatElements(payload?['elements']);
|
||||
|
||||
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
|
||||
rows.map(CachedMessage.fromDbRow).toList();
|
||||
@@ -531,14 +546,8 @@ class CachedMessage {
|
||||
};
|
||||
|
||||
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
|
||||
List<MessageAttachment>? attachments;
|
||||
final attaches = msg['attaches'];
|
||||
if (attaches is List && attaches.isNotEmpty) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
final full = Map<String, dynamic>.from(msg);
|
||||
final parsed = parseAttachments(full);
|
||||
return CachedMessage(
|
||||
id: msg['id']?.toString() ?? '',
|
||||
accountId: accountId,
|
||||
@@ -547,8 +556,9 @@ class CachedMessage {
|
||||
text: msg['text'] as String?,
|
||||
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
|
||||
status: (msg['status'] as String?) ?? 'sent',
|
||||
payload: Map<String, dynamic>.from(msg),
|
||||
attachments: attachments,
|
||||
payload: full,
|
||||
attachments: parsed.$1,
|
||||
isControl: parsed.$2,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -558,11 +568,6 @@ class MessagesModule {
|
||||
|
||||
MessagesModule(this._api);
|
||||
|
||||
/// Загружает историю сообщений для указанного чата.
|
||||
///
|
||||
/// [fromTime] — опционально, время от которого грузить (миллисекунды).
|
||||
/// Если не указано, грузит самые свежие.
|
||||
/// [count] — количество сообщений.
|
||||
Future<List<CachedMessage>> fetchHistory(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
@@ -573,10 +578,7 @@ class MessagesModule {
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'from':
|
||||
fromTime ??
|
||||
(DateTime.now().millisecondsSinceEpoch +
|
||||
86400000), // +1 день для запаса
|
||||
'from': fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000),
|
||||
'forward': forward,
|
||||
'backward': backward ?? count,
|
||||
'getMessages': true,
|
||||
@@ -614,9 +616,7 @@ class MessagesModule {
|
||||
|
||||
if (toSave.isNotEmpty) {
|
||||
try {
|
||||
await AppDatabase.saveMessages(
|
||||
toSave.map((m) => m.toDbRow()).toList(),
|
||||
);
|
||||
await AppDatabase.saveMessages(toSave.map((m) => m.toDbRow()).toList());
|
||||
} catch (e) {
|
||||
logger.e('saveMessages error: $e');
|
||||
}
|
||||
@@ -625,11 +625,6 @@ class MessagesModule {
|
||||
return toSave;
|
||||
}
|
||||
|
||||
/// Поиск сообщений в чате по строке [query] (opcode 73).
|
||||
///
|
||||
/// Возвращает сырые записи результата вида
|
||||
/// `{'message': {...}, 'highlights': [...]}`, отсортированные сервером
|
||||
/// от новых к старым.
|
||||
Future<List<Map<String, dynamic>>> searchMessages(
|
||||
int chatId,
|
||||
String query, {
|
||||
@@ -691,7 +686,6 @@ class MessagesModule {
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Загружает сообщения из локальной базы данных.
|
||||
Future<List<CachedMessage>> getLocalHistory(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
@@ -715,30 +709,8 @@ class MessagesModule {
|
||||
final id = m['id']?.toString();
|
||||
if (id == null) return null;
|
||||
|
||||
final linkRaw = m['link'];
|
||||
String? linkType;
|
||||
if (linkRaw is Map) {
|
||||
linkType = linkRaw['type'] as String?;
|
||||
}
|
||||
|
||||
List<MessageAttachment>? attachments;
|
||||
bool isControl = false;
|
||||
if (linkType == 'FORWARD') {
|
||||
final fwdMap = Map<String, dynamic>.from(m.cast());
|
||||
attachments = [ForwardedMessageAttachment.fromMap(fwdMap)];
|
||||
} else {
|
||||
final attaches = m['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
// Detect CONTROL
|
||||
if (attachments.any((a) => a.type == AttachmentType.control)) {
|
||||
isControl = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
final full = Map<String, dynamic>.from(m.cast());
|
||||
final parsed = CachedMessage.parseAttachments(full);
|
||||
|
||||
return CachedMessage(
|
||||
id: id,
|
||||
@@ -748,9 +720,9 @@ class MessagesModule {
|
||||
text: m['text']?.toString(),
|
||||
time: _parseIntField(m['time']),
|
||||
status: m['status']?.toString(),
|
||||
payload: Map<String, dynamic>.from(m.cast()),
|
||||
attachments: attachments,
|
||||
isControl: isControl,
|
||||
payload: full,
|
||||
attachments: parsed.$1,
|
||||
isControl: parsed.$2,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -789,20 +761,18 @@ class MessagesModule {
|
||||
'notifySender': true,
|
||||
};
|
||||
}
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': message,
|
||||
'notify': notify,
|
||||
};
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
return _sendAndExtractMessageId(payload, 'Ошибка отправки');
|
||||
}
|
||||
|
||||
Future<String> _sendAndExtractMessageId(
|
||||
Map<String, dynamic> payload,
|
||||
String defaultError,
|
||||
) async {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) {
|
||||
final msg = (response.payload is Map)
|
||||
? (response.payload['localizedMessage'] ??
|
||||
response.payload['message'] ??
|
||||
'Ошибка отправки')
|
||||
: 'Ошибка отправки';
|
||||
throw Exception(msg.toString());
|
||||
_throwSendError(response.payload, defaultError);
|
||||
}
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
@@ -815,11 +785,46 @@ class MessagesModule {
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Пересылает сообщение [messageId] из чата [sourceChatId] в [targetChatId].
|
||||
///
|
||||
/// Пересылка — это отдельное сообщение без текста и вложений, со ссылкой
|
||||
/// `link.type = FORWARD`, указывающей на оригинал. Сервер сам подставит
|
||||
/// тело оригинала в ответе.
|
||||
Never _throwSendError(dynamic payload, String fallback) {
|
||||
final msg = (payload is Map)
|
||||
? (payload['localizedMessage'] ?? payload['message'] ?? fallback)
|
||||
: fallback;
|
||||
throw Exception(msg.toString());
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _sentMessageMap(Packet response) {
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<T> _sendWithNotReadyRetry<T>({
|
||||
required Map<String, dynamic> payload,
|
||||
required int maxAttempts,
|
||||
required Duration retryDelay,
|
||||
required T Function(Packet response) onResult,
|
||||
required T onExhausted,
|
||||
}) async {
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
return onResult(response);
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return onExhausted;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return onExhausted;
|
||||
}
|
||||
|
||||
Future<String> forwardMessage(
|
||||
int targetChatId,
|
||||
int sourceChatId,
|
||||
@@ -844,24 +849,7 @@ class MessagesModule {
|
||||
'notify': notify,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) {
|
||||
final msg = (response.payload is Map)
|
||||
? (response.payload['localizedMessage'] ??
|
||||
response.payload['message'] ??
|
||||
'Ошибка пересылки')
|
||||
: 'Ошибка пересылки';
|
||||
throw Exception(msg.toString());
|
||||
}
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msgMap = data['message'];
|
||||
if (msgMap is Map) {
|
||||
final id = msgMap['id'];
|
||||
if (id != null) return id.toString();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
return _sendAndExtractMessageId(payload, 'Ошибка пересылки');
|
||||
}
|
||||
|
||||
static CachedMessage buildForwardMessage({
|
||||
@@ -955,18 +943,13 @@ class MessagesModule {
|
||||
],
|
||||
'attaches': [],
|
||||
};
|
||||
final response = await _api.sendRequest(Opcode.msgSend, {
|
||||
return _api.sendRequestOk(Opcode.msgSend, {
|
||||
'chatId': chatId,
|
||||
'message': message,
|
||||
'notify': true,
|
||||
});
|
||||
return response.isOk;
|
||||
}
|
||||
|
||||
/// Загружает отложенные (запланированные) сообщения чата.
|
||||
///
|
||||
/// В отличие от обычной истории, отложенные сообщения не сохраняются
|
||||
/// в локальную БД — они живут только до момента отправки.
|
||||
Future<List<CachedMessage>> fetchDelayedMessages(
|
||||
int accountId,
|
||||
int chatId,
|
||||
@@ -1009,10 +992,6 @@ class MessagesModule {
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Редактирует текст (подпись) обычного сообщения.
|
||||
///
|
||||
/// Поле `attachments` не передаётся — сервер сохраняет существующие
|
||||
/// вложения.
|
||||
Future<bool> editMessage(
|
||||
int chatId,
|
||||
String messageId, {
|
||||
@@ -1031,13 +1010,9 @@ class MessagesModule {
|
||||
};
|
||||
if (sendAttachments) payload['attachments'] = const <dynamic>[];
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgEdit, payload);
|
||||
return response.isOk;
|
||||
return _api.sendRequestOk(Opcode.msgEdit, payload);
|
||||
}
|
||||
|
||||
/// Редактирует отложенное сообщение: меняет текст и/или время отправки.
|
||||
///
|
||||
/// Вложения сервер сохраняет сам — в payload они не передаются.
|
||||
Future<bool> editScheduledMessage(
|
||||
int chatId,
|
||||
String messageId, {
|
||||
@@ -1052,14 +1027,10 @@ class MessagesModule {
|
||||
'chatId': chatId,
|
||||
'elements': <dynamic>[],
|
||||
'text': text,
|
||||
'delayedAttributes': {
|
||||
'timeToFire': timeToFire,
|
||||
'notifySender': true,
|
||||
},
|
||||
'delayedAttributes': {'timeToFire': timeToFire, 'notifySender': true},
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgEdit, payload);
|
||||
return response.isOk;
|
||||
return _api.sendRequestOk(Opcode.msgEdit, payload);
|
||||
}
|
||||
|
||||
Future<bool> deleteMessages(
|
||||
@@ -1081,8 +1052,7 @@ class MessagesModule {
|
||||
'itemType': itemType,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgDelete, payload);
|
||||
return response.isOk;
|
||||
return _api.sendRequestOk(Opcode.msgDelete, payload);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> sendButtonCallback({
|
||||
@@ -1168,7 +1138,6 @@ class MessagesModule {
|
||||
int? scheduledTime,
|
||||
int maxAttempts = 20,
|
||||
Duration retryDelay = const Duration(seconds: 1),
|
||||
Duration initialDelay = const Duration(seconds: 3),
|
||||
}) async {
|
||||
final message = <String, dynamic>{
|
||||
'isLive': false,
|
||||
@@ -1188,29 +1157,15 @@ class MessagesModule {
|
||||
'notifySender': true,
|
||||
};
|
||||
}
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': message,
|
||||
'notify': notify,
|
||||
};
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
await Future.delayed(initialDelay);
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (response.isOk) return true;
|
||||
return false;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return false;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return _sendWithNotReadyRetry<bool>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: (response) => response.isOk,
|
||||
onExhausted: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> requestPhotoUploadUrl() async {
|
||||
@@ -1246,29 +1201,15 @@ class MessagesModule {
|
||||
}
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Запрашивает URL для загрузки видео (опкод 82).
|
||||
Future<VideoUploadInfo?> requestVideoUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.videoUpload, {
|
||||
'uploaderType': 0,
|
||||
@@ -1293,9 +1234,6 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправляет сообщение с видео по [token], полученному из
|
||||
/// [requestVideoUploadUrl]. Сервер может ответить `attachment.not.ready`,
|
||||
/// пока обрабатывает загруженное видео — в этом случае запрос повторяется.
|
||||
Future<Map<String, dynamic>?> sendVideoMessage(
|
||||
int chatId,
|
||||
String token, {
|
||||
@@ -1323,33 +1261,15 @@ class MessagesModule {
|
||||
}
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Запрашивает URL для загрузки голосового сообщения (опкод 82).
|
||||
///
|
||||
/// Тот же опкод, что и у видео, но `uploaderType: 1, type: 2`. В ответе
|
||||
/// `videoId` — это идентификатор аудио (`audioId`), а `token` уже выдан и
|
||||
/// используется в [sendAudioMessage] после загрузки байтов.
|
||||
Future<AudioUploadInfo?> requestAudioUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.videoUpload, {
|
||||
'uploaderType': 1,
|
||||
@@ -1374,13 +1294,6 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправляет голосовое сообщение по [token], полученному из
|
||||
/// [requestAudioUploadUrl], после загрузки Ogg/Opus-байтов на CDN.
|
||||
///
|
||||
/// [duration] — длительность в миллисекундах. [wave] — hex-строка амплитуд
|
||||
/// для дорожки; если пусто, отправляется плоская (нулевая) волна, которую
|
||||
/// сервер принимает. Сервер может ответить `attachment.not.ready`, пока
|
||||
/// обрабатывает загрузку — запрос повторяется.
|
||||
Future<Map<String, dynamic>?> sendAudioMessage(
|
||||
int chatId,
|
||||
String token, {
|
||||
@@ -1413,30 +1326,15 @@ class MessagesModule {
|
||||
}
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Запрашивает URL для загрузки видеосообщения-кружка (опкод 82,
|
||||
/// `uploaderType: 1, type: 1`). Ответ — `vu.oneme.ru/uploadVideo` + token.
|
||||
Future<VideoUploadInfo?> requestVideoNoteUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.videoUpload, {
|
||||
'uploaderType': 1,
|
||||
@@ -1461,13 +1359,6 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправляет видеосообщение-кружок (`videoType: 1`) по [token], полученному
|
||||
/// из [requestVideoNoteUploadUrl], после загрузки MP4-байтов на CDN.
|
||||
///
|
||||
/// [duration] — длительность в мс. [wave] — амплитуды аудиодорожки (бинарь,
|
||||
/// 80 байт; нули допустимы). [thumbhash] — компактный хеш превью (опционально,
|
||||
/// сервер всё равно отдаёт собственный `previewData`). Повторяет запрос на
|
||||
/// `attachment.not.ready`, пока CDN обрабатывает загрузку.
|
||||
Future<Map<String, dynamic>?> sendVideoNoteMessage(
|
||||
int chatId,
|
||||
String token, {
|
||||
@@ -1496,26 +1387,13 @@ class MessagesModule {
|
||||
};
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> sendLocationMessage(
|
||||
@@ -1542,15 +1420,12 @@ class MessagesModule {
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
static const int _pollAnonymousFlag = 4;
|
||||
static const int _pollMultipleFlag = 1;
|
||||
|
||||
Future<Map<String, dynamic>?> sendPollMessage(
|
||||
int chatId,
|
||||
String title,
|
||||
@@ -1559,7 +1434,9 @@ class MessagesModule {
|
||||
bool anonymous = true,
|
||||
bool notify = true,
|
||||
}) async {
|
||||
final settings = (anonymous ? 4 : 0) | (multiple ? 1 : 0);
|
||||
final settings =
|
||||
(anonymous ? _pollAnonymousFlag : 0) |
|
||||
(multiple ? _pollMultipleFlag : 0);
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': {
|
||||
@@ -1579,13 +1456,7 @@ class MessagesModule {
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
void sendTyping(int chatId, String type) {
|
||||
@@ -1616,13 +1487,7 @@ class MessagesModule {
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||
@@ -1662,13 +1527,6 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Запрашивает у сервера ссылки на воспроизведение видео (opcode 83).
|
||||
///
|
||||
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
|
||||
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
|
||||
/// Возвращает все доступные progressive-MP4 качества (label → URL),
|
||||
/// отсортированные по убыванию. URL'ы — готовые подписанные ссылки на CDN,
|
||||
/// поддерживающие HTTP range, поэтому пригодны для стриминга.
|
||||
Future<Map<String, String>> getVideoSources({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
@@ -1713,7 +1571,6 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает один лучший progressive-MP4 (или HLS как запасной).
|
||||
Future<String?> getVideoUrl({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
@@ -1769,10 +1626,6 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
|
||||
///
|
||||
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
|
||||
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
|
||||
Future<String?> getFileUrl({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
@@ -1836,7 +1689,7 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
ChatsModule.applyContactUpdate(contactId);
|
||||
chats.applyContactUpdate(contactId);
|
||||
return fullName;
|
||||
}
|
||||
}
|
||||
@@ -1898,7 +1751,7 @@ class MessagesModule {
|
||||
ContactCache.putOptions(id, rawOpts.whereType<String>().toSet());
|
||||
}
|
||||
|
||||
ChatsModule.applyContactUpdate(id);
|
||||
chats.applyContactUpdate(id);
|
||||
resolvedAny = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../api.dart';
|
||||
import 'chats.dart';
|
||||
import 'messages.dart';
|
||||
@@ -42,11 +43,20 @@ class OutboxService {
|
||||
if (api.state != SessionState.online) break;
|
||||
final pending = CachedMessage.fromDbRow(row);
|
||||
final text = pending.text;
|
||||
if (text == null || text.isEmpty || pending.payload != null) continue;
|
||||
if (text == null || text.isEmpty) continue;
|
||||
|
||||
final payload = pending.payload;
|
||||
final replyToMessageId = _replyIdFromPayload(payload);
|
||||
final elements = _elementsFromPayload(payload);
|
||||
|
||||
try {
|
||||
final actualId =
|
||||
await messages.sendMessage(accountId, pending.chatId, text);
|
||||
final actualId = await messages.sendMessage(
|
||||
accountId,
|
||||
pending.chatId,
|
||||
text,
|
||||
replyToMessageId: replyToMessageId,
|
||||
elements: elements,
|
||||
);
|
||||
final sent = CachedMessage(
|
||||
id: actualId.isNotEmpty ? actualId : pending.id,
|
||||
accountId: accountId,
|
||||
@@ -55,28 +65,63 @@ class OutboxService {
|
||||
text: text,
|
||||
time: pending.time,
|
||||
status: 'sent',
|
||||
payload: payload,
|
||||
);
|
||||
await AppDatabase.saveMessages([sent.toDbRow()]);
|
||||
if (sent.id != pending.id) {
|
||||
await AppDatabase.deleteMessage(
|
||||
accountId, pending.chatId, pending.id);
|
||||
accountId,
|
||||
pending.chatId,
|
||||
pending.id,
|
||||
);
|
||||
}
|
||||
ChatsModule.emitMessageSent(pending.chatId, pending.id, sent);
|
||||
await ChatsModule.applyOutgoing(
|
||||
chats.emitMessageSent(pending.chatId, pending.id, sent);
|
||||
await chats.applyOutgoing(
|
||||
accountId,
|
||||
pending.chatId,
|
||||
messageId: sent.id,
|
||||
time: sent.time,
|
||||
text: text,
|
||||
status: 'sent',
|
||||
elements: elements.isEmpty ? null : elements,
|
||||
);
|
||||
} catch (_) {
|
||||
break;
|
||||
} catch (e) {
|
||||
logger.w('Outbox: отправка ${pending.id} не удалась: $e');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logger.e('Outbox flush: $e');
|
||||
} finally {
|
||||
_flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
int? _replyIdFromPayload(Map<String, dynamic>? payload) {
|
||||
if (payload == null) return null;
|
||||
final link = payload['link'];
|
||||
if (link is! Map) return null;
|
||||
if ((link['type'] as String?)?.toUpperCase() != 'REPLY') return null;
|
||||
final msg = link['message'];
|
||||
if (msg is Map) {
|
||||
final id = msg['id'];
|
||||
if (id is int) return id;
|
||||
if (id != null) return int.tryParse(id.toString());
|
||||
}
|
||||
final mid = link['messageId'];
|
||||
if (mid is int) return mid;
|
||||
if (mid != null) return int.tryParse(mid.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _elementsFromPayload(
|
||||
Map<String, dynamic>? payload,
|
||||
) {
|
||||
final raw = payload?['elements'];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../models/poll.dart';
|
||||
|
||||
class PollsModule extends ChangeNotifier {
|
||||
@@ -52,8 +53,8 @@ class PollsModule extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
} catch (_) {
|
||||
// тихо игнорируем — опрос просто не отобразится
|
||||
} catch (e) {
|
||||
logger.w('PollsModule.fetch: pollId=$pollId chatId=$chatId $e');
|
||||
} finally {
|
||||
_inFlight.remove(pollId);
|
||||
}
|
||||
@@ -84,7 +85,8 @@ class PollsModule extends ChangeNotifier {
|
||||
await fetch(chatId, messageId, pollId, force: true);
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logger.w('PollsModule.vote: pollId=$pollId chatId=$chatId $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ class SelfCheckService {
|
||||
|
||||
void checkNow() => unawaited(_check());
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void resume() {
|
||||
if (_api == null || _timer != null) return;
|
||||
_timer = Timer.periodic(interval, (_) => unawaited(_check()));
|
||||
checkNow();
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return;
|
||||
|
||||
@@ -47,12 +47,12 @@ class StickersModule {
|
||||
|
||||
Future<void> _loadFavorites() async {
|
||||
final favIds = <int>[];
|
||||
final favResp = await _api.sendRequest(Opcode.assetsUpdate, {
|
||||
final fav = await _api.sendRequestMap(Opcode.assetsUpdate, {
|
||||
'type': 'FAVORITE_STICKER',
|
||||
'sync': 0,
|
||||
});
|
||||
if (favResp.isOk && favResp.payload is Map) {
|
||||
final sections = favResp.payload['sections'];
|
||||
if (fav != null) {
|
||||
final sections = fav['sections'];
|
||||
if (sections is List) {
|
||||
for (final s in sections) {
|
||||
if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') {
|
||||
@@ -68,12 +68,12 @@ class StickersModule {
|
||||
final newSetIds = <int>[];
|
||||
int marker = 0;
|
||||
|
||||
final stickerResp = await _api.sendRequest(Opcode.assetsUpdate, {
|
||||
final stickerData = await _api.sendRequestMap(Opcode.assetsUpdate, {
|
||||
'type': 'STICKER',
|
||||
'sync': 0,
|
||||
});
|
||||
if (stickerResp.isOk && stickerResp.payload is Map) {
|
||||
final sections = stickerResp.payload['sections'];
|
||||
if (stickerData != null) {
|
||||
final sections = stickerData['sections'];
|
||||
if (sections is List) {
|
||||
for (final s in sections) {
|
||||
if (s is! Map) continue;
|
||||
@@ -91,16 +91,16 @@ class StickersModule {
|
||||
var guard = 0;
|
||||
while (marker != 0 && guard < 50) {
|
||||
guard++;
|
||||
final page = await _api.sendRequest(Opcode.assetsGet, {
|
||||
final page = await _api.sendRequestMap(Opcode.assetsGet, {
|
||||
'sectionId': 'NEW_STICKER_SETS',
|
||||
'from': marker,
|
||||
'count': 100,
|
||||
});
|
||||
if (!page.isOk || page.payload is! Map) break;
|
||||
if (page == null) break;
|
||||
final before = newSetIds.length;
|
||||
_appendIntList(newSetIds, page.payload['stickerSets']);
|
||||
_appendIntList(newSetIds, page['stickerSets']);
|
||||
if (newSetIds.length == before) break;
|
||||
final m = page.payload['marker'];
|
||||
final m = page['marker'];
|
||||
marker = m is int ? m : 0;
|
||||
}
|
||||
|
||||
@@ -112,47 +112,53 @@ class StickersModule {
|
||||
if (seen.add(id)) ordered.add(id);
|
||||
}
|
||||
_orderedSetIds = ordered;
|
||||
logger.i('Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних');
|
||||
logger.i(
|
||||
'Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних',
|
||||
);
|
||||
|
||||
await _ensureSetMetas(ordered);
|
||||
}
|
||||
|
||||
Future<void> _ensureSetMetas(List<int> ids) async {
|
||||
final missing = ids.where((id) => !_sets.containsKey(id)).toList();
|
||||
Future<void> _fetchAndCache<T>({
|
||||
required String type,
|
||||
required List<int> ids,
|
||||
required String listKey,
|
||||
required T Function(Map) fromMap,
|
||||
required Map<int, T> cache,
|
||||
}) async {
|
||||
final missing = ids.where((id) => !cache.containsKey(id)).toList();
|
||||
for (final batch in _chunk(missing, 100)) {
|
||||
final resp = await _api.sendRequest(Opcode.assetsGetByIds, {
|
||||
'type': 'STICKER_SET',
|
||||
final map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
|
||||
'type': type,
|
||||
'ids': batch,
|
||||
});
|
||||
if (!resp.isOk || resp.payload is! Map) continue;
|
||||
final list = resp.payload['stickerSets'];
|
||||
if (map == null) continue;
|
||||
final list = map[listKey];
|
||||
if (list is! List) continue;
|
||||
for (final e in list) {
|
||||
if (e is Map && e['id'] is int) {
|
||||
final set = StickerSet.fromMap(e);
|
||||
_sets[set.id] = set;
|
||||
cache[e['id'] as int] = fromMap(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureSetMetas(List<int> ids) => _fetchAndCache<StickerSet>(
|
||||
type: 'STICKER_SET',
|
||||
ids: ids,
|
||||
listKey: 'stickerSets',
|
||||
fromMap: StickerSet.fromMap,
|
||||
cache: _sets,
|
||||
);
|
||||
|
||||
Future<List<StickerItem>> ensureStickers(List<int> stickerIds) async {
|
||||
final missing = stickerIds.where((id) => !_stickers.containsKey(id)).toList();
|
||||
for (final batch in _chunk(missing, 100)) {
|
||||
final resp = await _api.sendRequest(Opcode.assetsGetByIds, {
|
||||
'type': 'STICKER',
|
||||
'ids': batch,
|
||||
});
|
||||
if (!resp.isOk || resp.payload is! Map) continue;
|
||||
final list = resp.payload['stickers'];
|
||||
if (list is! List) continue;
|
||||
for (final e in list) {
|
||||
if (e is Map && e['id'] is int) {
|
||||
final item = StickerItem.fromMap(e);
|
||||
_stickers[item.id] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _fetchAndCache<StickerItem>(
|
||||
type: 'STICKER',
|
||||
ids: stickerIds,
|
||||
listKey: 'stickers',
|
||||
fromMap: StickerItem.fromMap,
|
||||
cache: _stickers,
|
||||
);
|
||||
return stickerIds
|
||||
.map((id) => _stickers[id])
|
||||
.whereType<StickerItem>()
|
||||
@@ -167,9 +173,9 @@ class StickersModule {
|
||||
void cacheSet(StickerSet set) => _sets[set.id] = set;
|
||||
|
||||
Future<StickerSet?> resolveSetByLink(String link) async {
|
||||
final resp = await _api.sendRequest(Opcode.linkInfo, {'link': link});
|
||||
if (!resp.isOk || resp.payload is! Map) return null;
|
||||
final raw = resp.payload['stickerSet'];
|
||||
final map = await _api.sendRequestMap(Opcode.linkInfo, {'link': link});
|
||||
if (map == null) return null;
|
||||
final raw = map['stickerSet'];
|
||||
if (raw is! Map || raw['id'] is! int) return null;
|
||||
final set = StickerSet.fromMap(raw);
|
||||
_sets[set.id] = set;
|
||||
@@ -182,21 +188,21 @@ class StickersModule {
|
||||
}
|
||||
|
||||
Future<bool> favoriteSet(int setId) async {
|
||||
final resp = await _api.sendRequest(Opcode.assetsAdd, {
|
||||
final map = await _api.sendRequestMap(Opcode.assetsAdd, {
|
||||
'type': 'FAVORITE_STICKER_SET',
|
||||
'id': setId,
|
||||
});
|
||||
final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true;
|
||||
final ok = map != null && map['success'] == true;
|
||||
if (ok) _markFavorite(setId, true);
|
||||
return ok;
|
||||
}
|
||||
|
||||
Future<bool> unfavoriteSet(int setId) async {
|
||||
final resp = await _api.sendRequest(Opcode.assetsRemove, {
|
||||
final map = await _api.sendRequestMap(Opcode.assetsRemove, {
|
||||
'type': 'FAVORITE_STICKER_SET',
|
||||
'ids': [setId],
|
||||
});
|
||||
final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true;
|
||||
final ok = map != null && map['success'] == true;
|
||||
if (ok) _markFavorite(setId, false);
|
||||
return ok;
|
||||
}
|
||||
|
||||
Vendored
+19
-13
@@ -3,6 +3,8 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../../models/chat_info.dart';
|
||||
import '../../models/contact_info.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
|
||||
Api? _api;
|
||||
@@ -94,20 +96,20 @@ class InfoCache<T> {
|
||||
}
|
||||
|
||||
class ContactInfoFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
static final _cache = InfoCache<ContactInfo>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
static Future<ContactInfo?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
static ContactInfo? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
static Future<ContactInfo?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.contactInfo, {
|
||||
@@ -119,7 +121,7 @@ class ContactInfoFetch {
|
||||
if (contacts is! List || contacts.isEmpty) return null;
|
||||
final first = contacts.first;
|
||||
if (first is! Map) return null;
|
||||
return Map<String, dynamic>.from(first);
|
||||
return ContactInfo.fromMap(Map<String, dynamic>.from(first));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +131,10 @@ class PresenceFetch {
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
static Future<Map<String, dynamic>?> get(
|
||||
int id, {
|
||||
bool forceRefresh = false,
|
||||
}) => _cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
@@ -206,7 +210,9 @@ class PresenceFetch {
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(List<int> ids) async {
|
||||
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(
|
||||
List<int> ids,
|
||||
) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online || ids.isEmpty) {
|
||||
return const {};
|
||||
@@ -230,20 +236,20 @@ class PresenceFetch {
|
||||
}
|
||||
|
||||
class ChatInfoFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
static final _cache = InfoCache<ChatInfo>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
static Future<ChatInfo?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
static ChatInfo? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
static Future<ChatInfo?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.chatInfo, {
|
||||
@@ -255,6 +261,6 @@ class ChatInfoFetch {
|
||||
if (chats is! List || chats.isEmpty) return null;
|
||||
final first = chats.first;
|
||||
if (first is! Map) return null;
|
||||
return Map<String, dynamic>.from(first);
|
||||
return ChatInfo.fromMap(Map<String, dynamic>.from(first));
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -1,3 +1,5 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import '../../backend/modules/messages.dart';
|
||||
|
||||
class CachedChatMessages {
|
||||
@@ -8,12 +10,20 @@ class CachedChatMessages {
|
||||
}
|
||||
|
||||
class MessageSessionCache {
|
||||
static final Map<String, CachedChatMessages> _store = {};
|
||||
static const int _capacity = 24;
|
||||
|
||||
static final LinkedHashMap<String, CachedChatMessages> _store =
|
||||
LinkedHashMap<String, CachedChatMessages>();
|
||||
|
||||
static String _key(int accountId, int chatId) => '$accountId:$chatId';
|
||||
|
||||
static CachedChatMessages? get(int accountId, int chatId) =>
|
||||
_store[_key(accountId, chatId)];
|
||||
static CachedChatMessages? get(int accountId, int chatId) {
|
||||
final key = _key(accountId, chatId);
|
||||
final entry = _store.remove(key);
|
||||
if (entry == null) return null;
|
||||
_store[key] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
static void save(
|
||||
int accountId,
|
||||
@@ -22,10 +32,15 @@ class MessageSessionCache {
|
||||
required bool reachedStart,
|
||||
}) {
|
||||
if (messages.isEmpty) return;
|
||||
_store[_key(accountId, chatId)] = CachedChatMessages(
|
||||
final key = _key(accountId, chatId);
|
||||
_store.remove(key);
|
||||
_store[key] = CachedChatMessages(
|
||||
List<CachedMessage>.of(messages),
|
||||
reachedStart,
|
||||
);
|
||||
while (_store.length > _capacity) {
|
||||
_store.remove(_store.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
static void remove(int accountId, int chatId) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import 'call_controller.dart';
|
||||
|
||||
class CallBridge {
|
||||
@@ -26,14 +27,19 @@ class CallBridge {
|
||||
void init() {
|
||||
if (_started || !_android) return;
|
||||
_started = true;
|
||||
_events.receiveBroadcastStream().listen(_handle, onError: (_) {});
|
||||
_events.receiveBroadcastStream().listen(
|
||||
_handle,
|
||||
onError: (e) => logger.w('CallBridge.init: events stream error: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> checkInitialCall() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
_handle(await _method.invokeMethod<dynamic>('consumeInitialCall'));
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.checkInitialCall: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handle(Object? event) {
|
||||
@@ -52,7 +58,8 @@ class CallBridge {
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(dataStr);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logger.w('CallBridge._handle: action=$action jsonDecode failed: $e');
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map) return;
|
||||
@@ -66,28 +73,35 @@ class CallBridge {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('notifyAccepted', {'caller': caller});
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.notifyAccepted: caller=$caller $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> notifyEnded() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('notifyEnded');
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.notifyEnded: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelIncoming() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('cancelIncoming');
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.cancelIncoming: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> canUseFullScreenIntent() async {
|
||||
if (!_android) return true;
|
||||
try {
|
||||
return await _method.invokeMethod<bool>('canUseFullScreenIntent') ?? true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.canUseFullScreenIntent: $e');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +110,8 @@ class CallBridge {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('openFullScreenIntentSettings');
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.openFullScreenIntentSettings: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../backend/api.dart';
|
||||
import '../../backend/modules/calls.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
import '../protocol/packet.dart';
|
||||
import '../utils/parse.dart';
|
||||
import 'call_bridge.dart';
|
||||
import 'call_session.dart';
|
||||
import 'conversation_params.dart';
|
||||
@@ -83,14 +84,16 @@ class CallController {
|
||||
final params = ConversationParams.decode(vcp);
|
||||
if (params == null) return;
|
||||
|
||||
_emitIncoming(IncomingCall(
|
||||
conversationId: conversationId,
|
||||
callerId: callerId,
|
||||
isVideo: payload['type'] == 'VIDEO' || params.isVideo,
|
||||
params: params,
|
||||
country: payload['country'] as String?,
|
||||
isContact: payload['isContact'] as bool?,
|
||||
));
|
||||
_emitIncoming(
|
||||
IncomingCall(
|
||||
conversationId: conversationId,
|
||||
callerId: callerId,
|
||||
isVideo: payload['type'] == 'VIDEO' || params.isVideo,
|
||||
params: params,
|
||||
country: payload['country'] as String?,
|
||||
isContact: payload['isContact'] as bool?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void injectFromNative(Map<dynamic, dynamic> data, {bool autoAccept = false}) {
|
||||
@@ -100,11 +103,10 @@ class CallController {
|
||||
final params = ConversationParams.decode(vcp);
|
||||
if (params == null) return;
|
||||
|
||||
final conversationId =
|
||||
(data['conversationId'] ?? data['vcId'])?.toString();
|
||||
final conversationId = (data['conversationId'] ?? data['vcId'])?.toString();
|
||||
if (conversationId == null || conversationId.isEmpty) return;
|
||||
|
||||
final callerId = _asInt(data['callerId'] ?? data['suid']);
|
||||
final callerId = parseIntOrNull(data['callerId'] ?? data['suid']);
|
||||
if (callerId == null) return;
|
||||
|
||||
final type = (data['type'] ?? data['callType'])?.toString();
|
||||
@@ -139,17 +141,16 @@ class CallController {
|
||||
_canceled.add(null);
|
||||
}
|
||||
|
||||
static int? _asInt(Object? v) {
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) async {
|
||||
Future<CallSession> startOutgoing(
|
||||
int calleeId, {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
if (_active != null) throw StateError('уже идёт звонок');
|
||||
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
|
||||
final config = Ws2Config.fromEndpoint(out.endpoint, userId: out.callsUserId);
|
||||
final config = Ws2Config.fromEndpoint(
|
||||
out.endpoint,
|
||||
userId: out.callsUserId,
|
||||
);
|
||||
final session = CallSession(ws2Config: config, role: CallRole.caller);
|
||||
_bind(session);
|
||||
await session.start();
|
||||
@@ -163,8 +164,10 @@ class CallController {
|
||||
Future<CallSession> joinByLink(String token, {bool isVideo = false}) async {
|
||||
if (_active != null) throw StateError('уже идёт звонок');
|
||||
final params = await _calls!.joinByLink(token, isVideo: isVideo);
|
||||
final config =
|
||||
Ws2Config.fromEndpoint(params.endpoint, userId: params.callsUserId);
|
||||
final config = Ws2Config.fromEndpoint(
|
||||
params.endpoint,
|
||||
userId: params.callsUserId,
|
||||
);
|
||||
final session = CallSession(ws2Config: config, role: CallRole.joiner);
|
||||
_bind(session);
|
||||
await session.start();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import '../utils/parse.dart';
|
||||
import 'call_info.dart';
|
||||
import 'conversation_params.dart';
|
||||
import 'ws2_signaling.dart';
|
||||
@@ -50,11 +51,7 @@ class CallSession {
|
||||
final ConversationParams? params;
|
||||
final CallRole role;
|
||||
|
||||
CallSession({
|
||||
required this.ws2Config,
|
||||
required this.role,
|
||||
this.params,
|
||||
});
|
||||
CallSession({required this.ws2Config, required this.role, this.params});
|
||||
|
||||
Ws2Signaling? _signaling;
|
||||
RTCPeerConnection? _pc;
|
||||
@@ -151,8 +148,9 @@ class CallSession {
|
||||
|
||||
CallSessionState get currentState => _current;
|
||||
|
||||
int get elapsedSeconds =>
|
||||
_activeSince == null ? 0 : DateTime.now().difference(_activeSince!).inSeconds;
|
||||
int get elapsedSeconds => _activeSince == null
|
||||
? 0
|
||||
: DateTime.now().difference(_activeSince!).inSeconds;
|
||||
|
||||
void _setState(CallSessionState s) {
|
||||
if (_current == s || _current == CallSessionState.ended) return;
|
||||
@@ -174,7 +172,9 @@ class CallSession {
|
||||
signaling.done.then((_) => _end());
|
||||
await signaling.connect();
|
||||
_levelTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 300), (_) => unawaited(_sampleLevels()));
|
||||
const Duration(milliseconds: 300),
|
||||
(_) => unawaited(_sampleLevels()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sampleLevels() async {
|
||||
@@ -341,7 +341,8 @@ class CallSession {
|
||||
}
|
||||
|
||||
void _onHungup(Map<String, dynamic> msg) {
|
||||
final raw = msg['participantId'] ??
|
||||
final raw =
|
||||
msg['participantId'] ??
|
||||
(msg['participant'] is Map ? (msg['participant'] as Map)['id'] : null);
|
||||
if (raw is! int) return;
|
||||
if (raw == ws2Config.userId) {
|
||||
@@ -353,7 +354,8 @@ class CallSession {
|
||||
|
||||
void _onSessionState(Map<String, dynamic> msg) {
|
||||
logger.t(
|
||||
'[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}');
|
||||
'[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}',
|
||||
);
|
||||
}
|
||||
|
||||
void _resolveParticipants(Object? conversation) {
|
||||
@@ -413,10 +415,7 @@ class CallSession {
|
||||
|
||||
int? _externalId(Object? ext) {
|
||||
if (ext is! Map) return null;
|
||||
final v = ext['id'];
|
||||
if (v is int) return v;
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
return parseIntOrNull(ext['id']);
|
||||
}
|
||||
|
||||
bool? _handFrom(Object? participantState) {
|
||||
@@ -496,7 +495,7 @@ class CallSession {
|
||||
|
||||
_topology =
|
||||
(conversation is Map ? conversation['topology']?.toString() : null) ??
|
||||
_topology;
|
||||
_topology;
|
||||
logger.t('[call] connection role=$role peer=$_peerId topology=$_topology');
|
||||
|
||||
if (_topology == 'SERVER') {
|
||||
@@ -515,6 +514,8 @@ class CallSession {
|
||||
|
||||
await _setupKometProbe(pc);
|
||||
|
||||
if (_isDesktop) await _preferVp8Codecs(pc);
|
||||
|
||||
if (role == CallRole.caller) {
|
||||
_setState(CallSessionState.ringing);
|
||||
await _createAndSendOffer();
|
||||
@@ -597,7 +598,9 @@ class CallSession {
|
||||
if (frame != null && frame['t'] == 'chat') {
|
||||
final body = frame['text'];
|
||||
if (body is String && body.isNotEmpty) {
|
||||
_addChat(CallChatMessage(text: body, mine: false, time: DateTime.now()));
|
||||
_addChat(
|
||||
CallChatMessage(text: body, mine: false, time: DateTime.now()),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -634,7 +637,9 @@ class CallSession {
|
||||
final channel = _probeChannel;
|
||||
if (body.isEmpty || channel == null) return;
|
||||
try {
|
||||
channel.send(RTCDataChannelMessage(jsonEncode({'t': 'chat', 'text': body})));
|
||||
channel.send(
|
||||
RTCDataChannelMessage(jsonEncode({'t': 'chat', 'text': body})),
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
@@ -731,8 +736,10 @@ class CallSession {
|
||||
final local = await pc.getLocalDescription();
|
||||
final answerSdp = local?.sdp ?? answer.sdp ?? '';
|
||||
final ssrcs = _extractSsrcs(answerSdp);
|
||||
logger.t('[call][sfu] answer: ${_mLines(answerSdp)} m-lines, '
|
||||
'ssrcs=${ssrcs.length}');
|
||||
logger.t(
|
||||
'[call][sfu] answer: ${_mLines(answerSdp)} m-lines, '
|
||||
'ssrcs=${ssrcs.length}',
|
||||
);
|
||||
|
||||
await _signaling?.acceptProducer(
|
||||
description: answerSdp,
|
||||
@@ -749,13 +756,20 @@ class CallSession {
|
||||
await _signaling?.changeSimulcast(
|
||||
mediaSource: 'CAMERA',
|
||||
layers: const [
|
||||
{'rid': 'h', 'width': 1280, 'height': 720, 'fps': 30, 'bitrateKbps': 2000},
|
||||
{
|
||||
'rid': 'h',
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'fps': 30,
|
||||
'bitrateKbps': 2000,
|
||||
},
|
||||
],
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
int _mLines(String sdp) => RegExp(r'^m=', multiLine: true).allMatches(sdp).length;
|
||||
int _mLines(String sdp) =>
|
||||
RegExp(r'^m=', multiLine: true).allMatches(sdp).length;
|
||||
|
||||
List<int> _extractSsrcs(String sdp) {
|
||||
final set = <int>{};
|
||||
@@ -766,8 +780,7 @@ class CallSession {
|
||||
return set.toList();
|
||||
}
|
||||
|
||||
Future<void> _waitIceGathering(
|
||||
RTCPeerConnection pc, Duration timeout) async {
|
||||
Future<void> _waitIceGathering(RTCPeerConnection pc, Duration timeout) async {
|
||||
if (pc.iceGatheringState ==
|
||||
RTCIceGatheringState.RTCIceGatheringStateComplete) {
|
||||
return;
|
||||
@@ -809,7 +822,8 @@ class CallSession {
|
||||
|
||||
Future<void> _onRemoteTrack(RTCTrackEvent event) async {
|
||||
logger.t(
|
||||
'[call] remote track: ${event.track.kind} streams=${event.streams.length}');
|
||||
'[call] remote track: ${event.track.kind} streams=${event.streams.length}',
|
||||
);
|
||||
if (event.streams.isNotEmpty) {
|
||||
_remoteStreamRef = event.streams.first;
|
||||
_remoteStream.add(event.streams.first);
|
||||
@@ -853,8 +867,7 @@ class CallSession {
|
||||
if (pc == null || peerId == null) return;
|
||||
|
||||
final offer = await pc.createOffer({});
|
||||
final raw = offer.sdp ?? '';
|
||||
final sdp = _isDesktop ? _forceVp8(raw) : raw;
|
||||
final sdp = offer.sdp ?? '';
|
||||
await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type));
|
||||
logger.t('[call] our offer video: ${_videoDir(sdp)}');
|
||||
await _signaling?.transmitSdp(
|
||||
@@ -871,59 +884,25 @@ class CallSession {
|
||||
defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS;
|
||||
|
||||
String _forceVp8(String sdp) {
|
||||
final lines = sdp.split('\r\n');
|
||||
var mIdx = -1;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('m=video')) {
|
||||
mIdx = i;
|
||||
break;
|
||||
Future<void> _preferVp8Codecs(RTCPeerConnection pc) async {
|
||||
try {
|
||||
final caps = await getRtpSenderCapabilities('video');
|
||||
final all = caps.codecs ?? const <RTCRtpCodecCapability>[];
|
||||
final hasVp8 = all.any((c) => c.mimeType.toLowerCase() == 'video/vp8');
|
||||
if (!hasVp8) return;
|
||||
final preferred = all.where((c) {
|
||||
final m = c.mimeType.toLowerCase();
|
||||
return m == 'video/vp8' || m == 'video/rtx';
|
||||
}).toList();
|
||||
if (preferred.isEmpty) return;
|
||||
for (final t in await pc.getTransceivers()) {
|
||||
try {
|
||||
await t.setCodecPreferences(preferred);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.t('[call] setCodecPreferences недоступен: $e');
|
||||
}
|
||||
if (mIdx == -1) return sdp;
|
||||
|
||||
String? vp8;
|
||||
for (final l in lines) {
|
||||
final m = RegExp(r'^a=rtpmap:(\d+) VP8/90000').firstMatch(l);
|
||||
if (m != null) {
|
||||
vp8 = m.group(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (vp8 == null) return sdp;
|
||||
|
||||
String? rtx;
|
||||
for (final l in lines) {
|
||||
final m = RegExp('^a=fmtp:(\\d+) apt=$vp8\$').firstMatch(l);
|
||||
if (m != null) {
|
||||
rtx = m.group(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final keep = {vp8, ?rtx};
|
||||
final parts = lines[mIdx].split(' ');
|
||||
if (parts.length <= 3) return sdp;
|
||||
lines[mIdx] = [...parts.sublist(0, 3), ...keep].join(' ');
|
||||
|
||||
var end = lines.length;
|
||||
for (var i = mIdx + 1; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('m=')) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final ptLine = RegExp(r'^a=(?:rtpmap|fmtp|rtcp-fb):(\d+)');
|
||||
final result = <String>[];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (i > mIdx && i < end) {
|
||||
final m = ptLine.firstMatch(lines[i]);
|
||||
if (m != null && !keep.contains(m.group(1))) continue;
|
||||
}
|
||||
result.add(lines[i]);
|
||||
}
|
||||
return result.join('\r\n');
|
||||
}
|
||||
|
||||
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
|
||||
@@ -1038,7 +1017,8 @@ class CallSession {
|
||||
|
||||
Future<void> _applyMuted(bool muted, {bool announce = false}) async {
|
||||
_muted = muted;
|
||||
for (final track in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
|
||||
for (final track
|
||||
in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
|
||||
track.enabled = !muted;
|
||||
}
|
||||
_notifyInfo();
|
||||
@@ -1066,10 +1046,14 @@ class CallSession {
|
||||
MediaStream stream;
|
||||
try {
|
||||
stream = screen
|
||||
? await navigator.mediaDevices
|
||||
.getDisplayMedia(<String, dynamic>{'video': true, 'audio': false})
|
||||
: await navigator.mediaDevices
|
||||
.getUserMedia(<String, dynamic>{'video': true, 'audio': false});
|
||||
? await navigator.mediaDevices.getDisplayMedia(<String, dynamic>{
|
||||
'video': true,
|
||||
'audio': false,
|
||||
})
|
||||
: await navigator.mediaDevices.getUserMedia(<String, dynamic>{
|
||||
'video': true,
|
||||
'audio': false,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.t('[call] video capture failed: $e');
|
||||
return;
|
||||
@@ -1301,7 +1285,9 @@ class CallSession {
|
||||
_peerType = responderTypes.first.toString();
|
||||
}
|
||||
final deviceIdxs = p['responderDeviceIdxs'];
|
||||
if (deviceIdxs is List && deviceIdxs.isNotEmpty && deviceIdxs.first is int) {
|
||||
if (deviceIdxs is List &&
|
||||
deviceIdxs.isNotEmpty &&
|
||||
deviceIdxs.first is int) {
|
||||
_peerDeviceIdx = deviceIdxs.first as int;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppAmoled {
|
||||
static const prefKey = 'app_amoled';
|
||||
static final ValueNotifier<bool> current = ValueNotifier(false);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? false;
|
||||
}
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: false,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum BubbleBehavior { mutable, immutable }
|
||||
|
||||
class AppBubbleBehavior {
|
||||
static const prefKey = 'app_bubble_behavior';
|
||||
static final ValueNotifier<BubbleBehavior> current = ValueNotifier(
|
||||
BubbleBehavior.mutable,
|
||||
|
||||
static final _setting = PersistedEnum<BubbleBehavior>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: BubbleBehavior.mutable,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static Future<BubbleBehavior> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(prefKey);
|
||||
return _parse(val);
|
||||
}
|
||||
static ValueNotifier<BubbleBehavior> get current => _setting.current;
|
||||
|
||||
static Future<void> save(BubbleBehavior behavior) async {
|
||||
current.value = behavior;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, behavior.name);
|
||||
}
|
||||
static Future<BubbleBehavior> load() => _setting.load();
|
||||
|
||||
static BubbleBehavior _parse(String? val) {
|
||||
if (val == BubbleBehavior.immutable.name) return BubbleBehavior.immutable;
|
||||
return BubbleBehavior.mutable;
|
||||
}
|
||||
static Future<void> save(BubbleBehavior behavior) => _setting.save(behavior);
|
||||
|
||||
static BubbleBehavior _parse(String? val) =>
|
||||
enumFromName(BubbleBehavior.values, val, BubbleBehavior.mutable);
|
||||
|
||||
static String label(BubbleBehavior behavior) {
|
||||
switch (behavior) {
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum BubbleStyle { mobile, desktop }
|
||||
|
||||
class AppBubbleShape {
|
||||
static const prefKey = 'app_bubble_shape';
|
||||
static final ValueNotifier<BubbleStyle> current = ValueNotifier(
|
||||
BubbleStyle.mobile,
|
||||
|
||||
static final _setting = PersistedEnum<BubbleStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: BubbleStyle.mobile,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static Future<BubbleStyle> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(prefKey);
|
||||
return _parse(val);
|
||||
}
|
||||
static ValueNotifier<BubbleStyle> get current => _setting.current;
|
||||
|
||||
static Future<void> save(BubbleStyle style) async {
|
||||
current.value = style;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, style.name);
|
||||
}
|
||||
static Future<BubbleStyle> load() => _setting.load();
|
||||
|
||||
static BubbleStyle _parse(String? val) {
|
||||
if (val == BubbleStyle.desktop.name) return BubbleStyle.desktop;
|
||||
return BubbleStyle.mobile;
|
||||
}
|
||||
static Future<void> save(BubbleStyle style) => _setting.save(style);
|
||||
|
||||
static BubbleStyle _parse(String? val) =>
|
||||
enumFromName(BubbleStyle.values, val, BubbleStyle.mobile);
|
||||
|
||||
static String label(BubbleStyle style) {
|
||||
switch (style) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppCacheExtent {
|
||||
static const prefKey = 'app_cache_extent';
|
||||
@@ -9,21 +10,21 @@ class AppCacheExtent {
|
||||
static const double lowWarnThreshold = 2500;
|
||||
static const double highWarnThreshold = 7000;
|
||||
|
||||
static final ValueNotifier<double> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<double>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getDouble(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setDouble(key, value);
|
||||
},
|
||||
sanitize: clamp,
|
||||
);
|
||||
|
||||
static Future<double> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getDouble(prefKey);
|
||||
if (raw == null) return defaultValue;
|
||||
return clamp(raw);
|
||||
}
|
||||
static ValueNotifier<double> get current => _setting.current;
|
||||
|
||||
static Future<void> save(double value) async {
|
||||
final clamped = clamp(value);
|
||||
current.value = clamped;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(prefKey, clamped);
|
||||
}
|
||||
static Future<double> load() => _setting.load();
|
||||
|
||||
static Future<void> save(double value) => _setting.save(value);
|
||||
|
||||
static double clamp(double v) {
|
||||
if (v < min) return min;
|
||||
|
||||
@@ -1,43 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum ChatChromeStyle { color, blur, none }
|
||||
|
||||
class AppChatChrome {
|
||||
static const prefKey = 'app_chat_chrome';
|
||||
static final ValueNotifier<ChatChromeStyle> current =
|
||||
ValueNotifier(ChatChromeStyle.none);
|
||||
|
||||
static ChatChromeStyle _parse(String? value) {
|
||||
switch (value) {
|
||||
case 'color':
|
||||
return ChatChromeStyle.color;
|
||||
case 'blur':
|
||||
return ChatChromeStyle.blur;
|
||||
default:
|
||||
return ChatChromeStyle.none;
|
||||
}
|
||||
}
|
||||
static final _setting = PersistedEnum<ChatChromeStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ChatChromeStyle.none,
|
||||
encode: _encode,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static String _encode(ChatChromeStyle value) {
|
||||
switch (value) {
|
||||
case ChatChromeStyle.color:
|
||||
return 'color';
|
||||
case ChatChromeStyle.blur:
|
||||
return 'blur';
|
||||
case ChatChromeStyle.none:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
static ValueNotifier<ChatChromeStyle> get current => _setting.current;
|
||||
|
||||
static Future<ChatChromeStyle> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _parse(prefs.getString(prefKey));
|
||||
}
|
||||
static ChatChromeStyle _parse(String? value) =>
|
||||
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none);
|
||||
|
||||
static Future<void> save(ChatChromeStyle value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, _encode(value));
|
||||
}
|
||||
static String _encode(ChatChromeStyle value) => value.name;
|
||||
|
||||
static Future<ChatChromeStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(ChatChromeStyle value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension AppColorTokens on ColorScheme {
|
||||
Color get mutedText => onSurfaceVariant.withValues(alpha: 0.6);
|
||||
}
|
||||
|
||||
const int kAvatarThumbSize = 144;
|
||||
|
||||
const Color kReadReceiptBlue = Color(0xFF4FC3F7);
|
||||
const Color kOnlineGreen = Color(0xFF34C759);
|
||||
const Color kEditorAccent = Color(0xFF2F8FFF);
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppCommands {
|
||||
static const prefKey = 'dev_commands';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppDigitalIdNative {
|
||||
static const prefKey = 'app_digital_id_native';
|
||||
static final ValueNotifier<bool> current = ValueNotifier(false);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? false;
|
||||
}
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: false,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ class AppFont {
|
||||
final String label;
|
||||
final String? fontFamily;
|
||||
|
||||
const AppFont({
|
||||
required this.id,
|
||||
required this.label,
|
||||
this.fontFamily,
|
||||
});
|
||||
const AppFont({required this.id, required this.label, this.fontFamily});
|
||||
|
||||
bool get isSystem => fontFamily == null;
|
||||
bool get isCustom => id.startsWith(AppFonts.customPrefix);
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppLinkPreview {
|
||||
static const prefKey = 'dev_link_preview';
|
||||
static const bool defaultValue = true;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppMediaCacheLimit {
|
||||
static const prefKey = 'media_cache_limit_bytes';
|
||||
@@ -18,16 +19,18 @@ class AppMediaCacheLimit {
|
||||
unlimited,
|
||||
];
|
||||
|
||||
static final ValueNotifier<int> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<int>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getInt(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setInt(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<int> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(prefKey) ?? defaultValue;
|
||||
}
|
||||
static ValueNotifier<int> get current => _setting.current;
|
||||
|
||||
static Future<void> save(int value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(prefKey, value);
|
||||
}
|
||||
static Future<int> load() => _setting.load();
|
||||
|
||||
static Future<void> save(int value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum MessageActionsStyle { radial, list }
|
||||
|
||||
class AppMessageActionsStyle {
|
||||
static const prefKey = 'app_message_actions_style';
|
||||
static final ValueNotifier<MessageActionsStyle> current = ValueNotifier(
|
||||
MessageActionsStyle.radial,
|
||||
|
||||
static final _setting = PersistedEnum<MessageActionsStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: MessageActionsStyle.radial,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static Future<MessageActionsStyle> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _parse(prefs.getString(prefKey));
|
||||
}
|
||||
static ValueNotifier<MessageActionsStyle> get current => _setting.current;
|
||||
|
||||
static Future<void> save(MessageActionsStyle style) async {
|
||||
current.value = style;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, style.name);
|
||||
}
|
||||
static Future<MessageActionsStyle> load() => _setting.load();
|
||||
|
||||
static MessageActionsStyle _parse(String? val) {
|
||||
if (val == MessageActionsStyle.list.name) return MessageActionsStyle.list;
|
||||
return MessageActionsStyle.radial;
|
||||
}
|
||||
static Future<void> save(MessageActionsStyle style) => _setting.save(style);
|
||||
|
||||
static MessageActionsStyle _parse(String? val) =>
|
||||
enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.radial);
|
||||
|
||||
static String label(MessageActionsStyle style) {
|
||||
switch (style) {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppPillGradient {
|
||||
static const prefKey = 'app_pill_gradient';
|
||||
static final ValueNotifier<bool> current = ValueNotifier(true);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? true;
|
||||
}
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: true,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppPranks {
|
||||
static const prefKey = 'dev_pranks';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppShowExtraInfo {
|
||||
static const prefKey = 'dev_show_extra_info';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppStories {
|
||||
static const prefKey = 'dev_stories';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppSwipeBackDesktop {
|
||||
static const prefKey = 'dev_swipe_back_desktop';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum AppThemeMode { system, light, dark, schedule }
|
||||
|
||||
class AppThemeModeConfig {
|
||||
static const prefKey = 'app_theme_mode';
|
||||
static final ValueNotifier<AppThemeMode> current = ValueNotifier(
|
||||
AppThemeMode.system,
|
||||
|
||||
static final _setting = PersistedEnum<AppThemeMode>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: AppThemeMode.system,
|
||||
encode: (mode) => mode.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static Future<AppThemeMode> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _parse(prefs.getString(prefKey));
|
||||
}
|
||||
static ValueNotifier<AppThemeMode> get current => _setting.current;
|
||||
|
||||
static Future<void> save(AppThemeMode mode) async {
|
||||
current.value = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, mode.name);
|
||||
}
|
||||
static Future<AppThemeMode> load() => _setting.load();
|
||||
|
||||
static AppThemeMode _parse(String? val) {
|
||||
switch (val) {
|
||||
case 'light':
|
||||
return AppThemeMode.light;
|
||||
case 'dark':
|
||||
return AppThemeMode.dark;
|
||||
case 'schedule':
|
||||
return AppThemeMode.schedule;
|
||||
default:
|
||||
return AppThemeMode.system;
|
||||
}
|
||||
}
|
||||
static Future<void> save(AppThemeMode mode) => _setting.save(mode);
|
||||
|
||||
static AppThemeMode _parse(String? val) =>
|
||||
enumFromName(AppThemeMode.values, val, AppThemeMode.system);
|
||||
|
||||
static String label(AppThemeMode mode) {
|
||||
switch (mode) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/format.dart';
|
||||
|
||||
class ThemeSchedule {
|
||||
final TimeOfDay darkStart;
|
||||
final TimeOfDay darkEnd;
|
||||
@@ -44,7 +46,9 @@ class AppThemeSchedule {
|
||||
|
||||
static Future<ThemeSchedule> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _parse(prefs.getString(prefKey));
|
||||
final value = _parse(prefs.getString(prefKey));
|
||||
current.value = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
static Future<void> save(ThemeSchedule schedule) async {
|
||||
@@ -56,8 +60,7 @@ class AppThemeSchedule {
|
||||
);
|
||||
}
|
||||
|
||||
static String _fmt(TimeOfDay t) =>
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||
static String _fmt(TimeOfDay t) => '${pad2(t.hour)}:${pad2(t.minute)}';
|
||||
|
||||
static ThemeSchedule _parse(String? val) {
|
||||
if (val == null) {
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum VisualStyle { materialYou, glossy }
|
||||
|
||||
class AppVisualStyle {
|
||||
static const prefKey = 'app_visual_style';
|
||||
static final ValueNotifier<VisualStyle> current =
|
||||
ValueNotifier(VisualStyle.materialYou);
|
||||
|
||||
static Future<VisualStyle> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(prefKey) == 'glossy'
|
||||
? VisualStyle.glossy
|
||||
: VisualStyle.materialYou;
|
||||
}
|
||||
static final _setting = PersistedEnum<VisualStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: VisualStyle.materialYou,
|
||||
encode: _encode,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static Future<void> save(VisualStyle value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
prefKey,
|
||||
value == VisualStyle.glossy ? 'glossy' : 'materialYou',
|
||||
);
|
||||
}
|
||||
static ValueNotifier<VisualStyle> get current => _setting.current;
|
||||
|
||||
static Future<VisualStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(VisualStyle value) => _setting.save(value);
|
||||
|
||||
static String _encode(VisualStyle value) => value.name;
|
||||
|
||||
static VisualStyle _parse(String? val) =>
|
||||
enumFromName(VisualStyle.values, val, VisualStyle.materialYou);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ class CountryName {
|
||||
final String code; // ISO 3166-1 alpha-2, например "RU"
|
||||
final String en;
|
||||
final String ru;
|
||||
final String phoneCode; // Код страны для звонков, например "+7"
|
||||
final int phoneDigits; // Количество цифр номера после кода страны
|
||||
final String phoneMask; // Маска абонентского номера, например "(###) ###-##-##"
|
||||
final List<int> phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2]
|
||||
final List<String> phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""]
|
||||
final String phoneCode; // Код страны для звонков, например "+7"
|
||||
final int phoneDigits; // Количество цифр номера после кода страны
|
||||
final String
|
||||
phoneMask; // Маска абонентского номера, например "(###) ###-##-##"
|
||||
final List<int> phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2]
|
||||
final List<String>
|
||||
phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""]
|
||||
|
||||
const CountryName({
|
||||
required this.code,
|
||||
@@ -20,6 +22,10 @@ class CountryName {
|
||||
required this.phoneGroupSizes,
|
||||
required this.phoneGroupSeparators,
|
||||
});
|
||||
|
||||
String displayName(String languageCode) {
|
||||
return languageCode == 'ru' ? ru : en;
|
||||
}
|
||||
}
|
||||
|
||||
/// Полный список стран (195 государств) с названием на русском и английском.
|
||||
@@ -52,11 +58,7 @@ Map<String, String>? exampleCountryLookup(String code) {
|
||||
final country = countriesByCode[code.toUpperCase()];
|
||||
if (country == null) return null;
|
||||
|
||||
return {
|
||||
'code': country.code,
|
||||
'ru': country.ru,
|
||||
'en': country.en,
|
||||
};
|
||||
return {'code': country.code, 'ru': country.ru, 'en': country.en};
|
||||
}
|
||||
|
||||
List<CountryName> _buildCountries() {
|
||||
@@ -79,9 +81,19 @@ List<CountryName> _buildCountries() {
|
||||
final phoneDigits = item['phoneDigits'] as int;
|
||||
final phoneMask = item['phoneMask'] as String;
|
||||
final phoneGroupSizes = (item['phoneGroupSizes'] as List).cast<int>();
|
||||
final phoneGroupSeparators = (item['phoneGroupSeparators'] as List).cast<String>();
|
||||
final phoneGroupSeparators = (item['phoneGroupSeparators'] as List)
|
||||
.cast<String>();
|
||||
|
||||
return CountryName(code: code, en: en, ru: ru, phoneCode: phoneCode, phoneDigits: phoneDigits, phoneMask: phoneMask, phoneGroupSizes: phoneGroupSizes, phoneGroupSeparators: phoneGroupSeparators);
|
||||
return CountryName(
|
||||
code: code,
|
||||
en: en,
|
||||
ru: ru,
|
||||
phoneCode: phoneCode,
|
||||
phoneDigits: phoneDigits,
|
||||
phoneMask: phoneMask,
|
||||
phoneGroupSizes: phoneGroupSizes,
|
||||
phoneGroupSeparators: phoneGroupSeparators,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
countries.sort((a, b) => a.en.compareTo(b.en));
|
||||
|
||||
@@ -6,9 +6,13 @@ import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
class CustomFontService {
|
||||
static const String prefKey = 'app_custom_fonts';
|
||||
static const String _userAgent = 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120';
|
||||
static const String _userAgent =
|
||||
'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) '
|
||||
'AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30';
|
||||
|
||||
static final Set<String> _loaded = <String>{};
|
||||
|
||||
@@ -30,24 +34,26 @@ class CustomFontService {
|
||||
}
|
||||
|
||||
static Future<String?> addFamily(String family) async {
|
||||
final dir = await _cacheDir();
|
||||
final file = _fileFor(dir, family);
|
||||
Uint8List? bytes;
|
||||
if (await file.exists()) {
|
||||
bytes = await file.readAsBytes();
|
||||
} else {
|
||||
bytes = await _download(family);
|
||||
if (bytes == null) return null;
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
try {
|
||||
final dir = await _cacheDir();
|
||||
final file = _fileFor(dir, family);
|
||||
Uint8List? bytes;
|
||||
if (await file.exists()) {
|
||||
final cached = await file.readAsBytes();
|
||||
if (_isSfnt(cached)) bytes = cached;
|
||||
}
|
||||
if (bytes == null) {
|
||||
bytes = await _download(family);
|
||||
if (bytes == null) return null;
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
await _register(family, bytes);
|
||||
} catch (_) {
|
||||
if (await file.exists()) await file.delete();
|
||||
await _persist(family);
|
||||
return family;
|
||||
} catch (e) {
|
||||
logger.w('CustomFont: не удалось добавить «$family»: $e');
|
||||
return null;
|
||||
}
|
||||
await _persist(family);
|
||||
return family;
|
||||
}
|
||||
|
||||
static Future<void> removeFamily(String family) async {
|
||||
@@ -102,19 +108,23 @@ class CustomFontService {
|
||||
static Future<Uint8List?> _download(String family) async {
|
||||
final encoded = Uri.encodeQueryComponent(family);
|
||||
final variants = <String>[
|
||||
'https://fonts.googleapis.com/css2?family=$encoded:wght@100..900',
|
||||
'https://fonts.googleapis.com/css2?family=$encoded',
|
||||
'https://fonts.googleapis.com/css2?family=$encoded:wght@400',
|
||||
'https://fonts.googleapis.com/css2?family=$encoded:wght@100..900',
|
||||
];
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 15);
|
||||
final urlRegex = RegExp(r'url\((https://[^)]+)\)');
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 15);
|
||||
try {
|
||||
for (final url in variants) {
|
||||
final css = await _fetchText(client, Uri.parse(url));
|
||||
if (css == null) continue;
|
||||
final ttf = RegExp(r'url\((https://[^)]+\.ttf)\)').firstMatch(css);
|
||||
final ttfUrl = ttf?.group(1);
|
||||
if (ttfUrl == null) continue;
|
||||
final bytes = await _fetchBytes(client, Uri.parse(ttfUrl));
|
||||
if (bytes != null && _isSfnt(bytes)) return bytes;
|
||||
for (final match in urlRegex.allMatches(css)) {
|
||||
final fontUrl = match.group(1);
|
||||
if (fontUrl == null) continue;
|
||||
final bytes = await _fetchBytes(client, Uri.parse(fontUrl));
|
||||
if (bytes != null && _isSfnt(bytes)) return bytes;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
@@ -127,26 +137,27 @@ class CustomFontService {
|
||||
static Future<String?> _fetchText(HttpClient client, Uri uri) async {
|
||||
final req = await client.getUrl(uri);
|
||||
req.headers.set(HttpHeaders.userAgentHeader, _userAgent);
|
||||
final resp = await req.close();
|
||||
final resp = await req.close().timeout(const Duration(seconds: 20));
|
||||
if (resp.statusCode != HttpStatus.ok) {
|
||||
await resp.drain<void>();
|
||||
return null;
|
||||
}
|
||||
return resp.transform(const Utf8Decoder()).join();
|
||||
return resp
|
||||
.transform(const Utf8Decoder())
|
||||
.join()
|
||||
.timeout(const Duration(seconds: 20));
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _fetchBytes(HttpClient client, Uri uri) async {
|
||||
final req = await client.getUrl(uri);
|
||||
req.headers.set(HttpHeaders.userAgentHeader, _userAgent);
|
||||
final resp = await req.close();
|
||||
final resp = await req.close().timeout(const Duration(seconds: 20));
|
||||
if (resp.statusCode != HttpStatus.ok) {
|
||||
await resp.drain<void>();
|
||||
return null;
|
||||
}
|
||||
final builder = BytesBuilder(copy: false);
|
||||
await for (final chunk in resp) {
|
||||
builder.add(chunk);
|
||||
}
|
||||
await resp.forEach(builder.add).timeout(const Duration(seconds: 30));
|
||||
return builder.takeBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
typedef PrefReader<T> = T? Function(SharedPreferences prefs, String key);
|
||||
typedef PrefWriter<T> =
|
||||
Future<void> Function(SharedPreferences prefs, String key, T value);
|
||||
|
||||
class PersistedSetting<T> {
|
||||
PersistedSetting({
|
||||
required this.prefKey,
|
||||
required this.defaultValue,
|
||||
required this.read,
|
||||
required this.write,
|
||||
T Function(T value)? sanitize,
|
||||
}) : sanitize = sanitize ?? ((value) => value),
|
||||
current = ValueNotifier<T>(defaultValue);
|
||||
|
||||
final String prefKey;
|
||||
final T defaultValue;
|
||||
final PrefReader<T> read;
|
||||
final PrefWriter<T> write;
|
||||
final T Function(T value) sanitize;
|
||||
final ValueNotifier<T> current;
|
||||
|
||||
Future<T> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = sanitize(read(prefs, prefKey) ?? defaultValue);
|
||||
current.value = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> save(T value) async {
|
||||
final sanitized = sanitize(value);
|
||||
current.value = sanitized;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await write(prefs, prefKey, sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
typedef EnumEncoder<T extends Enum> = String Function(T value);
|
||||
typedef EnumDecoder<T extends Enum> = T Function(String? raw);
|
||||
|
||||
T enumFromName<T extends Enum>(List<T> values, String? raw, T fallback) =>
|
||||
values.firstWhere((v) => v.name == raw, orElse: () => fallback);
|
||||
|
||||
class PersistedEnum<T extends Enum> {
|
||||
PersistedEnum({
|
||||
required this.prefKey,
|
||||
required this.defaultValue,
|
||||
required this.encode,
|
||||
required this.decode,
|
||||
}) : current = ValueNotifier<T>(defaultValue);
|
||||
|
||||
final String prefKey;
|
||||
final T defaultValue;
|
||||
final EnumEncoder<T> encode;
|
||||
final EnumDecoder<T> decode;
|
||||
final ValueNotifier<T> current;
|
||||
|
||||
Future<T> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = decode(prefs.getString(prefKey));
|
||||
current.value = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> save(T value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, encode(value));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
enum ProxyType { none, socks5, httpConnect }
|
||||
|
||||
class ProxySettings {
|
||||
@@ -38,8 +40,22 @@ abstract class ProxyConfig {
|
||||
final typeIndex = prefs.getInt(_prefType) ?? 0;
|
||||
final host = prefs.getString(_prefHost) ?? '';
|
||||
final port = prefs.getInt(_prefPort) ?? 1080;
|
||||
final username = prefs.getString(_prefUsername);
|
||||
final password = prefs.getString(_prefPassword);
|
||||
var username = await TokenStorage.readSecure(_prefUsername);
|
||||
var password = await TokenStorage.readSecure(_prefPassword);
|
||||
final legacyUsername = prefs.getString(_prefUsername);
|
||||
final legacyPassword = prefs.getString(_prefPassword);
|
||||
if (username == null && legacyUsername != null) {
|
||||
username = legacyUsername;
|
||||
await TokenStorage.writeSecure(_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);
|
||||
}
|
||||
return ProxySettings(
|
||||
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
|
||||
host: host,
|
||||
@@ -54,15 +70,17 @@ abstract class ProxyConfig {
|
||||
await prefs.setInt(_prefType, settings.type.index);
|
||||
await prefs.setString(_prefHost, settings.host);
|
||||
await prefs.setInt(_prefPort, settings.port);
|
||||
await prefs.remove(_prefUsername);
|
||||
await prefs.remove(_prefPassword);
|
||||
if (settings.username != null) {
|
||||
await prefs.setString(_prefUsername, settings.username!);
|
||||
await TokenStorage.writeSecure(_prefUsername, settings.username!);
|
||||
} else {
|
||||
await prefs.remove(_prefUsername);
|
||||
await TokenStorage.deleteSecure(_prefUsername);
|
||||
}
|
||||
if (settings.password != null) {
|
||||
await prefs.setString(_prefPassword, settings.password!);
|
||||
await TokenStorage.writeSecure(_prefPassword, settings.password!);
|
||||
} else {
|
||||
await prefs.remove(_prefPassword);
|
||||
await TokenStorage.deleteSecure(_prefPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,5 +91,7 @@ abstract class ProxyConfig {
|
||||
await prefs.remove(_prefPort);
|
||||
await prefs.remove(_prefUsername);
|
||||
await prefs.remove(_prefPassword);
|
||||
await TokenStorage.deleteSecure(_prefUsername);
|
||||
await TokenStorage.deleteSecure(_prefPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,14 @@ class Checkers {
|
||||
return quiet;
|
||||
}
|
||||
|
||||
static void _collectCaptures(List<int> work, int at, CheckersSide side,
|
||||
List<int> path, Set<int> captured, List<List<int>> out) {
|
||||
static void _collectCaptures(
|
||||
List<int> work,
|
||||
int at,
|
||||
CheckersSide side,
|
||||
List<int> path,
|
||||
Set<int> captured,
|
||||
List<List<int>> out,
|
||||
) {
|
||||
final steps = _captureSteps(work, at, captured);
|
||||
if (steps.isEmpty) {
|
||||
if (path.length > 1) out.add(List<int>.of(path));
|
||||
@@ -93,7 +99,10 @@ class Checkers {
|
||||
}
|
||||
|
||||
static List<List<int>> _captureSteps(
|
||||
List<int> work, int at, Set<int> captured) {
|
||||
List<int> work,
|
||||
int at,
|
||||
Set<int> captured,
|
||||
) {
|
||||
final piece = work[at];
|
||||
final side = sideOf(piece);
|
||||
if (side == null) return const [];
|
||||
@@ -139,7 +148,11 @@ class Checkers {
|
||||
}
|
||||
|
||||
static void _collectQuiet(
|
||||
List<int> board, int at, CheckersSide side, List<List<int>> out) {
|
||||
List<int> board,
|
||||
int at,
|
||||
CheckersSide side,
|
||||
List<List<int>> out,
|
||||
) {
|
||||
final piece = board[at];
|
||||
final r0 = _row(at);
|
||||
final c0 = _col(at);
|
||||
|
||||
@@ -34,8 +34,10 @@ class MaxLink {
|
||||
if (match == null) return null;
|
||||
|
||||
final path = match.group(1)!.split('?').first.split('#').first;
|
||||
final segments =
|
||||
path.split('/').where((s) => s.isNotEmpty).toList(growable: false);
|
||||
final segments = path
|
||||
.split('/')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (segments.isEmpty) return null;
|
||||
|
||||
switch (segments.first.toLowerCase()) {
|
||||
|
||||
@@ -120,7 +120,14 @@ class _AssetGalleryItem implements GalleryItem {
|
||||
|
||||
class _DesktopGallerySource implements GallerySource {
|
||||
static const _imageExtensions = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
};
|
||||
|
||||
@override
|
||||
@@ -140,10 +147,7 @@ class _DesktopGallerySource implements GallerySource {
|
||||
} catch (_) {}
|
||||
}
|
||||
entries.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return entries
|
||||
.take(limit)
|
||||
.map((e) => _FileGalleryItem(e.file))
|
||||
.toList();
|
||||
return entries.take(limit).map((e) => _FileGalleryItem(e.file)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -77,7 +77,8 @@ class OpusOggEncoder {
|
||||
if (end <= pcm.length) {
|
||||
frame = Int16List.sublistView(pcm, off, end);
|
||||
} else {
|
||||
frame = Int16List(_frameSamples)..setRange(0, pcm.length - off, pcm, off);
|
||||
frame = Int16List(_frameSamples)
|
||||
..setRange(0, pcm.length - off, pcm, off);
|
||||
}
|
||||
packets.add(encoder.encode(input: frame));
|
||||
}
|
||||
@@ -87,12 +88,29 @@ class OpusOggEncoder {
|
||||
return _buildOgg(packets, totalSamples: pcm.length);
|
||||
}
|
||||
|
||||
static Uint8List _buildOgg(List<Uint8List> packets, {required int totalSamples}) {
|
||||
static Uint8List _buildOgg(
|
||||
List<Uint8List> packets, {
|
||||
required int totalSamples,
|
||||
}) {
|
||||
final out = BytesBuilder();
|
||||
var seq = 0;
|
||||
|
||||
out.add(_page(headerType: 0x02, granulePos: 0, seq: seq++, packets: [_opusHead()]));
|
||||
out.add(_page(headerType: 0x00, granulePos: 0, seq: seq++, packets: [_opusTags()]));
|
||||
out.add(
|
||||
_page(
|
||||
headerType: 0x02,
|
||||
granulePos: 0,
|
||||
seq: seq++,
|
||||
packets: [_opusHead()],
|
||||
),
|
||||
);
|
||||
out.add(
|
||||
_page(
|
||||
headerType: 0x00,
|
||||
granulePos: 0,
|
||||
seq: seq++,
|
||||
packets: [_opusTags()],
|
||||
),
|
||||
);
|
||||
|
||||
var pagePackets = <Uint8List>[];
|
||||
var pageSegments = 0;
|
||||
@@ -100,12 +118,14 @@ class OpusOggEncoder {
|
||||
|
||||
void flush({required bool last}) {
|
||||
final granule = last ? totalSamples + _preSkip : samples + _preSkip;
|
||||
out.add(_page(
|
||||
headerType: last ? 0x04 : 0x00,
|
||||
granulePos: granule,
|
||||
seq: seq++,
|
||||
packets: pagePackets,
|
||||
));
|
||||
out.add(
|
||||
_page(
|
||||
headerType: last ? 0x04 : 0x00,
|
||||
granulePos: granule,
|
||||
seq: seq++,
|
||||
packets: pagePackets,
|
||||
),
|
||||
);
|
||||
pagePackets = <Uint8List>[];
|
||||
pageSegments = 0;
|
||||
}
|
||||
@@ -214,7 +234,8 @@ class OpusOggEncoder {
|
||||
static int _crc32(Uint8List data) {
|
||||
var crc = 0;
|
||||
for (final b in data) {
|
||||
crc = (((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) &
|
||||
crc =
|
||||
(((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) &
|
||||
0xffffffff;
|
||||
}
|
||||
return crc & 0xffffffff;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../utils/image_utils.dart';
|
||||
|
||||
Future<File?> rasterPictureToJpegFile(
|
||||
ui.Picture picture,
|
||||
int width,
|
||||
int height, {
|
||||
required String prefix,
|
||||
void Function()? onPictureDisposed,
|
||||
}) async {
|
||||
final rendered = await picture.toImage(width, height);
|
||||
picture.dispose();
|
||||
onPictureDisposed?.call();
|
||||
final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
rendered.dispose();
|
||||
if (bd == null) return null;
|
||||
|
||||
final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), width, height);
|
||||
if (jpeg == null) return null;
|
||||
final dir = await getTemporaryDirectory();
|
||||
final out = File(
|
||||
p.join(
|
||||
dir.path,
|
||||
'komet_${prefix}_${DateTime.now().microsecondsSinceEpoch}.jpg',
|
||||
),
|
||||
);
|
||||
await out.writeAsBytes(jpeg);
|
||||
return out;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/parse.dart';
|
||||
|
||||
enum NfcEventType { received, exchanging, cancelled, error }
|
||||
|
||||
class NfcEvent {
|
||||
@@ -60,17 +62,19 @@ class NfcExchangeService {
|
||||
|
||||
NfcEvent _decodeEvent(dynamic raw) {
|
||||
final map = raw is Map ? raw : const {};
|
||||
final id = map['id'];
|
||||
final parsedId = id is int ? id : (id is num ? id.toInt() : null);
|
||||
final phone = map['phone'];
|
||||
final parsedPhone = phone is int ? phone : (phone is num ? phone.toInt() : null);
|
||||
final parsedId = parseIntOrNull(map['id']);
|
||||
final parsedPhone = parseIntOrNull(map['phone']);
|
||||
switch (map['event']) {
|
||||
case 'received':
|
||||
return NfcEvent(NfcEventType.received, parsedId, phone: parsedPhone);
|
||||
case 'exchanging':
|
||||
return const NfcEvent(NfcEventType.exchanging, null);
|
||||
case 'error':
|
||||
return NfcEvent(NfcEventType.error, null, reason: map['reason'] as String?);
|
||||
return NfcEvent(
|
||||
NfcEventType.error,
|
||||
null,
|
||||
reason: map['reason'] as String?,
|
||||
);
|
||||
default:
|
||||
return const NfcEvent(NfcEventType.cancelled, null);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ const int _maxDecompressedSize = 1048576; // 1 MB
|
||||
|
||||
/// Типы команд в протоколе
|
||||
abstract class CmdType {
|
||||
static const int request = 0; // запрос клиента / пуш от сервера (направление определяет смысл)
|
||||
static const int request =
|
||||
0; // запрос клиента / пуш от сервера (направление определяет смысл)
|
||||
static const int push = 0; // пуш от сервера (имеет смысл только для incoming)
|
||||
|
||||
static const int ok = 1; // ответ: ок
|
||||
@@ -83,6 +84,21 @@ String messageFromErrorPayload(dynamic payload) {
|
||||
return s.isNotEmpty ? s : 'Неизвестная ошибка';
|
||||
}
|
||||
|
||||
bool isSessionExpiredPayload(dynamic payload) {
|
||||
return payload is Map &&
|
||||
(payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
||||
payload['message'] == 'FAIL_WRONG_PASSWORD');
|
||||
}
|
||||
|
||||
void throwIfPacketError(Packet packet) {
|
||||
if (!packet.isError) return;
|
||||
final payload = packet.payload;
|
||||
if (isSessionExpiredPayload(payload)) {
|
||||
throw SessionExpiredException(messageFromErrorPayload(payload));
|
||||
}
|
||||
throw PacketError(messageFromErrorPayload(payload));
|
||||
}
|
||||
|
||||
bool isSessionStateError(Object error) {
|
||||
if (error is SessionExpiredException) return true;
|
||||
final text = error.toString().toLowerCase();
|
||||
@@ -192,7 +208,9 @@ Uint8List _decompressPayload(Uint8List src) {
|
||||
src[2] == 0x2F &&
|
||||
src[3] == 0xFD) {
|
||||
try {
|
||||
return ZstdCodec(maxDecompressedSize: _maxDecompressedSize).decompress(src);
|
||||
return ZstdCodec(
|
||||
maxDecompressedSize: _maxDecompressedSize,
|
||||
).decompress(src);
|
||||
} catch (e) {
|
||||
throw Exception('Zstd decompression error: $e');
|
||||
}
|
||||
@@ -218,4 +236,3 @@ Uint8List _decompressPayload(Uint8List src) {
|
||||
throw Exception('LZ4 block decompression error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-293
@@ -1,11 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -23,279 +20,14 @@ import '../utils/logger.dart';
|
||||
const _channelId = 'komet_messages';
|
||||
const _channelName = 'Сообщения';
|
||||
const _prefsTokenKey = 'fcm_push_token';
|
||||
const _groupKey = 'komet_messages_group';
|
||||
const _callNotifId = 424242;
|
||||
const _historyLimit = 6;
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _backgroundHandler(RemoteMessage message) async {}
|
||||
|
||||
class _NotifMessage {
|
||||
_NotifMessage(this.text, this.senderKey, this.senderName, this.ts);
|
||||
final String text;
|
||||
final String senderKey;
|
||||
final String senderName;
|
||||
final int ts;
|
||||
}
|
||||
|
||||
Future<void> _showMessageNotification(
|
||||
FlutterLocalNotificationsPlugin plugin,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final chatId = int.tryParse(data['mc']?.toString() ?? '') ?? 0;
|
||||
final senderKey = data['suid']?.toString() ?? '';
|
||||
final senderName =
|
||||
data['userName']?.toString() ?? data['title']?.toString() ?? 'MAX';
|
||||
final chatTitle = data['title']?.toString() ?? senderName;
|
||||
final text = data['msg']?.toString() ??
|
||||
data['body']?.toString() ??
|
||||
data['text']?.toString() ??
|
||||
data['message']?.toString() ??
|
||||
'Новое сообщение';
|
||||
final ts = int.tryParse(data['ctime']?.toString() ?? '') ??
|
||||
int.tryParse(data['ttime']?.toString() ?? '') ??
|
||||
DateTime.now().millisecondsSinceEpoch;
|
||||
final isGroup = chatTitle != senderName;
|
||||
final account = int.tryParse(data['c']?.toString() ?? '') ?? 0;
|
||||
final replyTo = int.tryParse(data['msgid']?.toString() ?? '');
|
||||
|
||||
final notifId = (chatId != 0 ? chatId : senderKey.hashCode) & 0x7fffffff;
|
||||
if (!await _isActive(plugin, notifId)) {
|
||||
await _clearHistory(chatId);
|
||||
}
|
||||
|
||||
final photo = await _avatarBytes(senderKey);
|
||||
final avatar = photo ?? await _initialsAvatar(senderName);
|
||||
print('PUSHDBG avatar sender=$senderKey photo=${photo?.length} '
|
||||
'final=${avatar?.length}');
|
||||
final history = await _appendHistory(chatId, senderKey, senderName, text, ts);
|
||||
|
||||
final persons = <String, Person>{};
|
||||
Person personFor(String key, String name) => persons.putIfAbsent(
|
||||
key,
|
||||
() => Person(
|
||||
key: key,
|
||||
name: name,
|
||||
icon: (key == senderKey && avatar != null)
|
||||
? ByteArrayAndroidIcon(avatar)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
final messages = [
|
||||
for (final h in history)
|
||||
Message(
|
||||
h.text,
|
||||
DateTime.fromMillisecondsSinceEpoch(h.ts),
|
||||
personFor(h.senderKey, h.senderName),
|
||||
),
|
||||
];
|
||||
|
||||
final style = MessagingStyleInformation(
|
||||
const Person(name: 'Вы'),
|
||||
conversationTitle: isGroup ? chatTitle : null,
|
||||
groupConversation: isGroup,
|
||||
messages: messages,
|
||||
);
|
||||
|
||||
await plugin.show(
|
||||
id: notifId,
|
||||
title: chatTitle,
|
||||
body: text,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.message,
|
||||
styleInformation: style,
|
||||
groupKey: _groupKey,
|
||||
largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null,
|
||||
ticker: text,
|
||||
actions: account != 0
|
||||
? const [
|
||||
AndroidNotificationAction(
|
||||
'reply',
|
||||
'Ответить',
|
||||
inputs: [
|
||||
AndroidNotificationActionInput(label: 'Сообщение…'),
|
||||
],
|
||||
semanticAction: SemanticAction.reply,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
payload: jsonEncode({'c': account, 'chat': chatId, 'mid': replyTo}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCallNotification(
|
||||
FlutterLocalNotificationsPlugin plugin,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final name =
|
||||
data['userName']?.toString() ?? data['msg']?.toString() ?? 'Неизвестный';
|
||||
final avatar = await _avatarBytes(data['suid']?.toString() ?? '') ??
|
||||
await _initialsAvatar(name);
|
||||
await plugin.show(
|
||||
id: _callNotifId,
|
||||
title: 'Входящий звонок',
|
||||
body: name,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.call,
|
||||
largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null,
|
||||
ticker: 'Входящий звонок',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<_NotifMessage>> _appendHistory(
|
||||
int chatId,
|
||||
String senderKey,
|
||||
String senderName,
|
||||
String text,
|
||||
int ts,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = 'notif_hist_$chatId';
|
||||
final list = <Map<String, dynamic>>[];
|
||||
final raw = prefs.getString(key);
|
||||
if (raw != null) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
for (final e in decoded) {
|
||||
if (e is Map) list.add(e.cast<String, dynamic>());
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
list.add({'t': text, 'k': senderKey, 'n': senderName, 'ts': ts});
|
||||
while (list.length > _historyLimit) {
|
||||
list.removeAt(0);
|
||||
}
|
||||
await prefs.setString(key, jsonEncode(list));
|
||||
return [
|
||||
for (final e in list)
|
||||
_NotifMessage(
|
||||
e['t']?.toString() ?? '',
|
||||
e['k']?.toString() ?? '',
|
||||
e['n']?.toString() ?? '',
|
||||
int.tryParse(e['ts']?.toString() ?? '') ?? ts,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<bool> _isActive(FlutterLocalNotificationsPlugin plugin, int id) async {
|
||||
try {
|
||||
final active = await plugin.getActiveNotifications();
|
||||
return active.any((n) => n.id == id);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearHistory(int chatId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('notif_hist_$chatId');
|
||||
}
|
||||
|
||||
const _avatarPalette = <int>[
|
||||
0xFF5B8DEF,
|
||||
0xFFEF5B8D,
|
||||
0xFF3FB950,
|
||||
0xFFE3883A,
|
||||
0xFF9B72F0,
|
||||
0xFF2AA9B5,
|
||||
0xFFE05252,
|
||||
0xFF6A7BE0,
|
||||
];
|
||||
|
||||
String _initialsOf(String name) {
|
||||
final parts =
|
||||
name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
|
||||
if (parts.isEmpty) return '?';
|
||||
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
|
||||
return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase();
|
||||
}
|
||||
|
||||
Future<Uint8List?> _initialsAvatar(String name) async {
|
||||
try {
|
||||
const size = 128;
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(recorder);
|
||||
final paint = ui.Paint()
|
||||
..isAntiAlias = true
|
||||
..color = ui.Color(
|
||||
_avatarPalette[name.isEmpty ? 0 : name.hashCode.abs() % _avatarPalette.length],
|
||||
);
|
||||
canvas.drawCircle(const ui.Offset(64, 64), 64, paint);
|
||||
final builder = ui.ParagraphBuilder(
|
||||
ui.ParagraphStyle(
|
||||
textAlign: ui.TextAlign.center,
|
||||
fontSize: 56,
|
||||
fontWeight: ui.FontWeight.w600,
|
||||
),
|
||||
)
|
||||
..pushStyle(ui.TextStyle(color: const ui.Color(0xFFFFFFFF)))
|
||||
..addText(_initialsOf(name));
|
||||
final paragraph = builder.build()
|
||||
..layout(const ui.ParagraphConstraints(width: 128));
|
||||
canvas.drawParagraph(paragraph, ui.Offset(0, (size - paragraph.height) / 2));
|
||||
final image = await recorder.endRecording().toImage(size, size);
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
if (data == null) return null;
|
||||
return data.buffer.asUint8List();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> _avatarBytes(String senderKey) async {
|
||||
if (senderKey.isEmpty) return null;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString('contact_cache_v1');
|
||||
if (raw == null) return null;
|
||||
final map = jsonDecode(raw);
|
||||
if (map is! Map) return null;
|
||||
final entry = map[senderKey];
|
||||
final url = entry is Map ? entry['a']?.toString() : null;
|
||||
if (url == null || url.isEmpty) return null;
|
||||
return await _downloadBytes(url);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> _downloadBytes(String url) async {
|
||||
HttpClient? client;
|
||||
try {
|
||||
client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
|
||||
final req = await client.getUrl(Uri.parse(url));
|
||||
final resp = await req.close().timeout(const Duration(seconds: 5));
|
||||
if (resp.statusCode != 200) return null;
|
||||
return await consolidateHttpClientResponseBytes(resp);
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
client?.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void _onNotificationResponse(NotificationResponse response) {
|
||||
print('REPLYDBG cb action=${response.actionId} '
|
||||
'input=${response.input} payload=${response.payload}');
|
||||
if (response.actionId == 'call_decline') {
|
||||
final payload = response.payload;
|
||||
if (payload != null) unawaited(_handleCallDecline(payload));
|
||||
@@ -329,9 +61,7 @@ Future<void> _handleCallDecline(String payloadJson) async {
|
||||
try {
|
||||
await signaling.connect();
|
||||
await signaling.hangup(reason: 'REJECTED');
|
||||
print('REPLYDBG call decline sent');
|
||||
} catch (e) {
|
||||
print('REPLYDBG call decline error $e');
|
||||
} catch (_) {
|
||||
} finally {
|
||||
await signaling.close();
|
||||
}
|
||||
@@ -351,7 +81,6 @@ Future<void> _handleReply(String payloadJson, String text) async {
|
||||
return;
|
||||
}
|
||||
if (account == 0 || chatId == 0) return;
|
||||
print('REPLYDBG start acc=$account chat=$chatId reply=$replyTo');
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if (AppInstance.isNamed) {
|
||||
@@ -366,7 +95,6 @@ Future<void> _handleReply(String payloadJson, String text) async {
|
||||
var sent = false;
|
||||
try {
|
||||
final token = await TokenStorage.readToken(account);
|
||||
print('REPLYDBG token=${token != null && token.isNotEmpty}');
|
||||
if (token != null && token.isNotEmpty) {
|
||||
api = Api()..spoofScope = '$account';
|
||||
await api.connect();
|
||||
@@ -375,30 +103,19 @@ Future<void> _handleReply(String payloadJson, String text) async {
|
||||
.firstWhere((s) => s == SessionState.online)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
}
|
||||
print('REPLYDBG online');
|
||||
final login = await api.sendRequest(Opcode.login, <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'interactive': false,
|
||||
'exp': {
|
||||
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
|
||||
},
|
||||
'presenceSync': 0,
|
||||
});
|
||||
print('REPLYDBG login ok=${login.isOk}');
|
||||
final login = await api.sendRequest(
|
||||
Opcode.login,
|
||||
AccountModule(api).buildLoginPayload(token, interactive: false),
|
||||
);
|
||||
if (login.isOk) {
|
||||
await MessagesModule(api).sendMessage(
|
||||
account,
|
||||
chatId,
|
||||
text,
|
||||
replyToMessageId: replyTo,
|
||||
);
|
||||
await MessagesModule(
|
||||
api,
|
||||
).sendMessage(account, chatId, text, replyToMessageId: replyTo);
|
||||
sent = true;
|
||||
print('REPLYDBG sent');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
sent = false;
|
||||
print('REPLYDBG error $e');
|
||||
} finally {
|
||||
await api?.disconnect();
|
||||
}
|
||||
@@ -464,7 +181,8 @@ class PushService {
|
||||
);
|
||||
await _local
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
_channelId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:komet/core/storage/app_instance.dart';
|
||||
@@ -166,7 +167,8 @@ class AppDatabase {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return dir.path;
|
||||
}
|
||||
return _mobileDbDir ??= await databaseFactorySqflitePlugin.getDatabasesPath();
|
||||
return _mobileDbDir ??= await databaseFactorySqflitePlugin
|
||||
.getDatabasesPath();
|
||||
}
|
||||
|
||||
static Future<void> _migrateLegacyDb(String target) async {
|
||||
@@ -180,7 +182,9 @@ class AppDatabase {
|
||||
await legacy.copy(target);
|
||||
logger.i('[db] перенёс komet.db -> $target');
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('legacy db migration failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Database> _open() async {
|
||||
@@ -190,13 +194,16 @@ class AppDatabase {
|
||||
await _migrateLegacyDb(target);
|
||||
return openDatabase(
|
||||
target,
|
||||
version: 16,
|
||||
version: 17,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
await _addColumnIfMissing(
|
||||
db, 'profile', 'is_active', 'INTEGER NOT NULL DEFAULT 0',
|
||||
db,
|
||||
'profile',
|
||||
'is_active',
|
||||
'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
await db.execute('DROP TABLE IF EXISTS sync_state');
|
||||
await db.execute(_syncStateSchema);
|
||||
@@ -232,16 +239,27 @@ class AppDatabase {
|
||||
await _createIndexes(db);
|
||||
}
|
||||
if (oldVersion < 12) {
|
||||
await _addColumnIfMissing(db, 'chats_cache', 'last_msg_status', 'TEXT');
|
||||
await _addColumnIfMissing(
|
||||
db,
|
||||
'chats_cache',
|
||||
'last_msg_status',
|
||||
'TEXT',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 13) {
|
||||
await _addColumnIfMissing(
|
||||
db, 'messages', 'deleted', 'INTEGER NOT NULL DEFAULT 0',
|
||||
db,
|
||||
'messages',
|
||||
'deleted',
|
||||
'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 14) {
|
||||
await _addColumnIfMissing(
|
||||
db, 'chats_cache', 'in_list', 'INTEGER NOT NULL DEFAULT 1',
|
||||
db,
|
||||
'chats_cache',
|
||||
'in_list',
|
||||
'INTEGER NOT NULL DEFAULT 1',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 15) {
|
||||
@@ -249,9 +267,17 @@ class AppDatabase {
|
||||
}
|
||||
if (oldVersion < 16) {
|
||||
await _addColumnIfMissing(
|
||||
db, 'chats_cache', 'last_msg_elements', 'TEXT',
|
||||
db,
|
||||
'chats_cache',
|
||||
'last_msg_elements',
|
||||
'TEXT',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 17) {
|
||||
await db.execute(_chatParticipantsSchema);
|
||||
await _createChatParticipantsIndex(db);
|
||||
await _backfillChatParticipants(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -277,7 +303,9 @@ class AppDatabase {
|
||||
await db.execute(_chatsCacheSchema);
|
||||
await db.execute(_contactsSchema);
|
||||
await db.execute(_messagesSchema);
|
||||
await db.execute(_chatParticipantsSchema);
|
||||
await _createIndexes(db);
|
||||
await _createChatParticipantsIndex(db);
|
||||
}
|
||||
|
||||
static Future<void> _addColumnIfMissing(
|
||||
@@ -304,6 +332,51 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _createChatParticipantsIndex(Database db) async {
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_chat_participants_lookup '
|
||||
'ON chat_participants(account_id, participant_id, chat_id)',
|
||||
);
|
||||
}
|
||||
|
||||
static List<int> _participantIdsFromRaw(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) return const [];
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return const [];
|
||||
final ids = <int>[];
|
||||
for (final key in decoded.keys) {
|
||||
final id = key is int ? key : int.tryParse(key.toString());
|
||||
if (id != null) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _backfillChatParticipants(Database db) async {
|
||||
final chats = await db.query(
|
||||
'chats_cache',
|
||||
columns: ['id', 'account_id', 'participants'],
|
||||
where: "type = 'DIALOG'",
|
||||
);
|
||||
final batch = db.batch();
|
||||
for (final chat in chats) {
|
||||
final accountId = chat['account_id'];
|
||||
final chatId = chat['id'];
|
||||
if (accountId is! int || chatId is! int) continue;
|
||||
for (final pid in _participantIdsFromRaw(chat['participants'])) {
|
||||
batch.insert('chat_participants', {
|
||||
'account_id': accountId,
|
||||
'chat_id': chatId,
|
||||
'participant_id': pid,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
}
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
static const _contactsSchema = '''
|
||||
CREATE TABLE contacts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -357,6 +430,17 @@ class AppDatabase {
|
||||
)
|
||||
''';
|
||||
|
||||
static const _chatParticipantsSchema = '''
|
||||
CREATE TABLE chat_participants (
|
||||
account_id INTEGER NOT NULL,
|
||||
chat_id INTEGER NOT NULL,
|
||||
participant_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, chat_id, participant_id),
|
||||
FOREIGN KEY (chat_id, account_id)
|
||||
REFERENCES chats_cache (id, account_id) ON DELETE CASCADE
|
||||
)
|
||||
''';
|
||||
|
||||
static const _messagesSchema = '''
|
||||
CREATE TABLE messages (
|
||||
id TEXT NOT NULL,
|
||||
@@ -374,7 +458,10 @@ class AppDatabase {
|
||||
)
|
||||
''';
|
||||
|
||||
static Future<void> saveProfile(ProfileData profile, {bool isActive = true}) async {
|
||||
static Future<void> saveProfile(
|
||||
ProfileData profile, {
|
||||
bool isActive = true,
|
||||
}) async {
|
||||
final db = await _instance;
|
||||
final row = profile.toDbRow(isActive: isActive);
|
||||
final cols = row.keys.toList();
|
||||
@@ -524,7 +611,8 @@ class AppDatabase {
|
||||
.where((c) => c != 'id' && c != 'account_id')
|
||||
.map((c) => '$c = excluded.$c')
|
||||
.join(', ');
|
||||
final sql = 'INSERT INTO chats_cache (${cols.join(', ')}) '
|
||||
final sql =
|
||||
'INSERT INTO chats_cache (${cols.join(', ')}) '
|
||||
'VALUES ($placeholders) '
|
||||
'ON CONFLICT(id, account_id) DO UPDATE SET $updates';
|
||||
await db.transaction((txn) async {
|
||||
@@ -533,13 +621,35 @@ class AppDatabase {
|
||||
batch.rawInsert(sql, cols.map((c) => row[c]).toList());
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
for (final row in rows) {
|
||||
if (!row.containsKey('participants')) continue;
|
||||
if (row['type'] != 'DIALOG') continue;
|
||||
final accountId = row['account_id'];
|
||||
final chatId = row['id'];
|
||||
if (accountId is! int || chatId is! int) continue;
|
||||
await txn.delete(
|
||||
'chat_participants',
|
||||
where: 'account_id = ? AND chat_id = ?',
|
||||
whereArgs: [accountId, chatId],
|
||||
);
|
||||
for (final pid in _participantIdsFromRaw(row['participants'])) {
|
||||
await txn.insert('chat_participants', {
|
||||
'account_id': accountId,
|
||||
'chat_id': chatId,
|
||||
'participant_id': pid,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при сохранении чата: $e");
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadChat(int accountId, int chatId) async {
|
||||
static Future<List<Map<String, dynamic>>> loadChat(
|
||||
int accountId,
|
||||
int chatId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'chats_cache',
|
||||
@@ -548,7 +658,7 @@ class AppDatabase {
|
||||
orderBy: 'last_event_time DESC',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadChats(int accountId) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
@@ -575,20 +685,25 @@ class AppDatabase {
|
||||
return (result.first['total'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
static Future<int?> findDialogChatByParticipant(int accountId, int contactId) async {
|
||||
static Future<int?> findDialogChatByParticipant(
|
||||
int accountId,
|
||||
int contactId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'chats_cache',
|
||||
columns: ['id'],
|
||||
where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?",
|
||||
whereArgs: [accountId, '%"$contactId":%'],
|
||||
limit: 1,
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT p.chat_id AS id FROM chat_participants p '
|
||||
'JOIN chats_cache c ON c.id = p.chat_id AND c.account_id = p.account_id '
|
||||
"WHERE p.account_id = ? AND p.participant_id = ? AND c.type = 'DIALOG' "
|
||||
'LIMIT 1',
|
||||
[accountId, contactId],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['id'] as int?;
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadDialogChats(int accountId) async {
|
||||
static Future<List<Map<String, dynamic>>> loadDialogChats(
|
||||
int accountId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'chats_cache',
|
||||
@@ -597,8 +712,10 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
static String _escapeLike(String value) =>
|
||||
value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
|
||||
static String _escapeLike(String value) => value
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('%', '\\%')
|
||||
.replaceAll('_', '\\_');
|
||||
|
||||
static Future<List<Map<String, dynamic>>> searchContacts(
|
||||
int accountId,
|
||||
@@ -611,7 +728,8 @@ class AppDatabase {
|
||||
final like = '%${_escapeLike(term)}%';
|
||||
return db.query(
|
||||
'contacts',
|
||||
where: 'account_id = ? AND '
|
||||
where:
|
||||
'account_id = ? AND '
|
||||
"(first_name LIKE ? ESCAPE '\\' OR last_name LIKE ? ESCAPE '\\' "
|
||||
"OR CAST(phone AS TEXT) LIKE ? ESCAPE '\\')",
|
||||
whereArgs: [accountId, like, like, like],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import 'per_chat_json_store.dart';
|
||||
|
||||
enum ChatWallpaperKind { image, theme }
|
||||
|
||||
@@ -39,18 +40,18 @@ class ChatWallpaper {
|
||||
this.blur = false,
|
||||
this.motion = false,
|
||||
this.offsetX = 0,
|
||||
}) : kind = ChatWallpaperKind.image,
|
||||
imagePath = path,
|
||||
themeId = null;
|
||||
}) : kind = ChatWallpaperKind.image,
|
||||
imagePath = path,
|
||||
themeId = null;
|
||||
|
||||
const ChatWallpaper.theme(String id)
|
||||
: kind = ChatWallpaperKind.theme,
|
||||
imagePath = null,
|
||||
themeId = id,
|
||||
dim = 0,
|
||||
blur = false,
|
||||
motion = false,
|
||||
offsetX = 0;
|
||||
: kind = ChatWallpaperKind.theme,
|
||||
imagePath = null,
|
||||
themeId = id,
|
||||
dim = 0,
|
||||
blur = false,
|
||||
motion = false,
|
||||
offsetX = 0;
|
||||
|
||||
bool get isImage => kind == ChatWallpaperKind.image;
|
||||
|
||||
@@ -82,42 +83,19 @@ class ChatWallpaper {
|
||||
}
|
||||
}
|
||||
|
||||
class ChatWallpaperStore {
|
||||
ChatWallpaperStore._();
|
||||
class ChatWallpaperStore extends PerChatJsonStore<ChatWallpaper> {
|
||||
ChatWallpaperStore._()
|
||||
: super(
|
||||
prefsKey: 'chat_wallpapers',
|
||||
fromJson: ChatWallpaper._fromJson,
|
||||
toJson: (value) => value._toJson(),
|
||||
);
|
||||
|
||||
static final ChatWallpaperStore instance = ChatWallpaperStore._();
|
||||
|
||||
static const String _prefsKey = 'chat_wallpapers';
|
||||
static const String _dirName = 'chat_wallpapers';
|
||||
|
||||
final Map<String, ChatWallpaper> _wallpapers = {};
|
||||
final ValueNotifier<int> revision = ValueNotifier(0);
|
||||
bool _loaded = false;
|
||||
|
||||
String _key(int accountId, int chatId) => '$accountId/$chatId';
|
||||
|
||||
Future<void> load() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_prefsKey);
|
||||
if (raw == null) return;
|
||||
try {
|
||||
final map = jsonDecode(raw);
|
||||
if (map is Map) {
|
||||
map.forEach((k, v) {
|
||||
if (k is! String) return;
|
||||
final wp = ChatWallpaper._fromJson(v);
|
||||
if (wp != null) _wallpapers[k] = wp;
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
ChatWallpaper? get(int accountId, int chatId) {
|
||||
if (accountId == 0) return null;
|
||||
return _wallpapers[_key(accountId, chatId)];
|
||||
}
|
||||
ChatWallpaper? get(int accountId, int chatId) => read(accountId, chatId);
|
||||
|
||||
Future<ChatWallpaper?> setImage(
|
||||
int accountId,
|
||||
@@ -139,7 +117,7 @@ class ChatWallpaperStore {
|
||||
motion: settings.motion,
|
||||
offsetX: settings.offsetX,
|
||||
);
|
||||
await _store(accountId, chatId, wallpaper);
|
||||
await write(accountId, chatId, wallpaper);
|
||||
return wallpaper;
|
||||
}
|
||||
|
||||
@@ -149,36 +127,20 @@ class ChatWallpaperStore {
|
||||
String themeId,
|
||||
) async {
|
||||
final wallpaper = ChatWallpaper.theme(themeId);
|
||||
await _store(accountId, chatId, wallpaper);
|
||||
await write(accountId, chatId, wallpaper);
|
||||
return wallpaper;
|
||||
}
|
||||
|
||||
Future<void> clear(int accountId, int chatId) => _store(accountId, chatId, null);
|
||||
Future<void> clear(int accountId, int chatId) =>
|
||||
write(accountId, chatId, null);
|
||||
|
||||
Future<void> _store(
|
||||
int accountId,
|
||||
int chatId,
|
||||
ChatWallpaper? wallpaper,
|
||||
) async {
|
||||
if (accountId == 0) return;
|
||||
final key = _key(accountId, chatId);
|
||||
final previous = _wallpapers[key];
|
||||
@override
|
||||
void onBeforeWrite(String key, ChatWallpaper? previous, ChatWallpaper? next) {
|
||||
if (previous != null &&
|
||||
previous.isImage &&
|
||||
previous.imagePath != wallpaper?.imagePath) {
|
||||
previous.imagePath != next?.imagePath) {
|
||||
unawaited(_deleteImage(previous.imagePath));
|
||||
}
|
||||
if (wallpaper == null) {
|
||||
if (previous == null) return;
|
||||
_wallpapers.remove(key);
|
||||
} else {
|
||||
_wallpapers[key] = wallpaper;
|
||||
}
|
||||
revision.value++;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final serializable = <String, dynamic>{};
|
||||
_wallpapers.forEach((k, v) => serializable[k] = v._toJson());
|
||||
await prefs.setString(_prefsKey, jsonEncode(serializable));
|
||||
}
|
||||
|
||||
Future<void> _deleteImage(String? path) async {
|
||||
@@ -186,6 +148,8 @@ class ChatWallpaperStore {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('wallpaper image delete failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:math';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/ids.dart';
|
||||
|
||||
abstract class DeviceIdentity {
|
||||
static const String _instanceIdKey = 'mt_instance_id';
|
||||
static const String _deviceIdKey = 'device_id_local';
|
||||
@@ -16,7 +18,7 @@ abstract class DeviceIdentity {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing = prefs.getString(_instanceIdKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final generated = _uuidV4();
|
||||
final generated = uuidV4();
|
||||
await prefs.setString(_instanceIdKey, generated);
|
||||
return generated;
|
||||
}
|
||||
@@ -25,25 +27,9 @@ abstract class DeviceIdentity {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing = prefs.getString(_deviceIdKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final generated = _hex(8);
|
||||
final generated = randomHex(8);
|
||||
await prefs.setString(_deviceIdKey, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
static String _hex(int bytes) {
|
||||
final sb = StringBuffer();
|
||||
for (var i = 0; i < bytes; i++) {
|
||||
sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static String _uuidV4() {
|
||||
final b = List<int>.generate(16, (_) => _rng.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String h(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-'
|
||||
'${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,27 @@
|
||||
import 'dart:convert';
|
||||
import 'per_chat_json_store.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class DraftStore {
|
||||
DraftStore._();
|
||||
class DraftStore extends PerChatJsonStore<String> {
|
||||
DraftStore._()
|
||||
: super(
|
||||
prefsKey: 'chat_drafts',
|
||||
fromJson: (raw) => raw is String ? raw : null,
|
||||
toJson: (value) => value,
|
||||
);
|
||||
|
||||
static final DraftStore instance = DraftStore._();
|
||||
|
||||
static const String _prefsKey = 'chat_drafts';
|
||||
|
||||
final Map<String, String> _drafts = {};
|
||||
final ValueNotifier<int> revision = ValueNotifier(0);
|
||||
bool _loaded = false;
|
||||
|
||||
String _key(int accountId, int chatId) => '$accountId/$chatId';
|
||||
|
||||
Future<void> load() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_prefsKey);
|
||||
if (raw == null) return;
|
||||
try {
|
||||
final map = jsonDecode(raw);
|
||||
if (map is Map) {
|
||||
map.forEach((k, v) {
|
||||
if (k is String && v is String) _drafts[k] = v;
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String? get(int accountId, int chatId) {
|
||||
if (accountId == 0) return null;
|
||||
return _drafts[_key(accountId, chatId)];
|
||||
}
|
||||
String? get(int accountId, int chatId) => read(accountId, chatId);
|
||||
|
||||
Future<void> set(int accountId, int chatId, String text) async {
|
||||
if (accountId == 0) return;
|
||||
final key = _key(accountId, chatId);
|
||||
final current = _drafts[key];
|
||||
final current = read(accountId, chatId);
|
||||
if (text.trim().isEmpty) {
|
||||
if (current == null) return;
|
||||
_drafts.remove(key);
|
||||
await write(accountId, chatId, null);
|
||||
} else {
|
||||
if (current == text) return;
|
||||
_drafts[key] = text;
|
||||
await write(accountId, chatId, text);
|
||||
}
|
||||
revision.value++;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKey, jsonEncode(_drafts));
|
||||
}
|
||||
|
||||
Future<void> clear(int accountId, int chatId) => set(accountId, chatId, '');
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class PerChatJsonStore<T> {
|
||||
PerChatJsonStore({
|
||||
required String prefsKey,
|
||||
required T? Function(Object? raw) fromJson,
|
||||
required Object? Function(T value) toJson,
|
||||
}) : _prefsKey = prefsKey,
|
||||
_fromJson = fromJson,
|
||||
_toJson = toJson;
|
||||
|
||||
final String _prefsKey;
|
||||
final T? Function(Object? raw) _fromJson;
|
||||
final Object? Function(T value) _toJson;
|
||||
|
||||
final Map<String, T> _values = {};
|
||||
final ValueNotifier<int> revision = ValueNotifier(0);
|
||||
bool _loaded = false;
|
||||
|
||||
String _buildKey(int accountId, int chatId) => '$accountId/$chatId';
|
||||
|
||||
@protected
|
||||
void onBeforeWrite(String key, T? previous, T? next) {}
|
||||
|
||||
Future<void> load() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_prefsKey);
|
||||
if (raw == null) return;
|
||||
try {
|
||||
final map = jsonDecode(raw);
|
||||
if (map is Map) {
|
||||
map.forEach((k, v) {
|
||||
if (k is! String) return;
|
||||
final value = _fromJson(v);
|
||||
if (value != null) _values[k] = value;
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@protected
|
||||
T? read(int accountId, int chatId) {
|
||||
if (accountId == 0) return null;
|
||||
return _values[_buildKey(accountId, chatId)];
|
||||
}
|
||||
|
||||
@protected
|
||||
Future<void> write(int accountId, int chatId, T? value) async {
|
||||
if (accountId == 0) return;
|
||||
final key = _buildKey(accountId, chatId);
|
||||
final previous = _values[key];
|
||||
onBeforeWrite(key, previous, value);
|
||||
if (value == null) {
|
||||
if (previous == null) return;
|
||||
_values.remove(key);
|
||||
} else {
|
||||
_values[key] = value;
|
||||
}
|
||||
revision.value++;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final serializable = <String, dynamic>{};
|
||||
_values.forEach((k, v) => serializable[k] = _toJson(v));
|
||||
await prefs.setString(_prefsKey, jsonEncode(serializable));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../config/device_presets.dart';
|
||||
import '../../models/spoof_profile.dart';
|
||||
import 'token_storage.dart';
|
||||
import '../utils/ids.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
class SpoofingService {
|
||||
static const String hardcodedAppVersion = '26.20.2';
|
||||
@@ -84,8 +86,9 @@ class SpoofingService {
|
||||
final fresh = devicePresets
|
||||
.where((p) => isAndroid(p) && !used.contains(p.deviceName))
|
||||
.toList();
|
||||
final pool =
|
||||
fresh.isNotEmpty ? fresh : devicePresets.where(isAndroid).toList();
|
||||
final pool = fresh.isNotEmpty
|
||||
? fresh
|
||||
: devicePresets.where(isAndroid).toList();
|
||||
final preset = pool[_rng.nextInt(pool.length)];
|
||||
final shortLocale = preset.locale.split(RegExp(r'[-_]')).first;
|
||||
|
||||
@@ -103,7 +106,7 @@ class SpoofingService {
|
||||
appVersion: hardcodedAppVersion,
|
||||
buildNumber: hardcodedBuildNumber,
|
||||
pushDeviceType: 'GCM',
|
||||
instanceId: _uuidV4(),
|
||||
instanceId: uuidV4(),
|
||||
clientSessionId: _rng.nextInt(0x7FFFFFFF) + 1,
|
||||
userAgent: preset.userAgent,
|
||||
);
|
||||
@@ -131,11 +134,13 @@ class SpoofingService {
|
||||
'device_locale': profile.deviceLocale,
|
||||
'device_id': profile.deviceId,
|
||||
'device_type': profile.deviceType,
|
||||
'app_version':
|
||||
profile.appVersion.isEmpty ? hardcodedAppVersion : profile.appVersion,
|
||||
'app_version': profile.appVersion.isEmpty
|
||||
? hardcodedAppVersion
|
||||
: profile.appVersion,
|
||||
'arch': profile.arch.isEmpty ? 'arm64-v8a' : profile.arch,
|
||||
'build_number':
|
||||
profile.buildNumber == 0 ? hardcodedBuildNumber : profile.buildNumber,
|
||||
'build_number': profile.buildNumber == 0
|
||||
? hardcodedBuildNumber
|
||||
: profile.buildNumber,
|
||||
'instance_id': profile.instanceId,
|
||||
'client_session_id': profile.clientSessionId,
|
||||
'push_device_type': profile.pushDeviceType,
|
||||
@@ -155,14 +160,16 @@ class SpoofingService {
|
||||
}
|
||||
|
||||
static String _deriveUserAgent(SpoofProfile profile) {
|
||||
final deviceType =
|
||||
profile.deviceType.isEmpty ? 'ANDROID' : profile.deviceType;
|
||||
final deviceType = profile.deviceType.isEmpty
|
||||
? 'ANDROID'
|
||||
: profile.deviceType;
|
||||
final osVersion = profile.osVersion;
|
||||
final model = profile.deviceName.isEmpty ? 'K' : profile.deviceName;
|
||||
|
||||
if (deviceType == 'IOS' || deviceType == 'iOS') {
|
||||
final version =
|
||||
osVersion.replaceAll(RegExp(r'[^0-9.]'), '').replaceAll('.', '_');
|
||||
final version = osVersion
|
||||
.replaceAll(RegExp(r'[^0-9.]'), '')
|
||||
.replaceAll('.', '_');
|
||||
return 'Mozilla/5.0 (iPhone; CPU iPhone OS '
|
||||
'${version.isEmpty ? '17_0' : version} like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 '
|
||||
@@ -181,10 +188,13 @@ class SpoofingService {
|
||||
final raw = prefs.getString(_profileKey(scope));
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
final profile =
|
||||
SpoofProfile.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
final profile = SpoofProfile.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
return _migrateVersion(prefs, scope, profile);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('spoof profile read failed: $e');
|
||||
}
|
||||
}
|
||||
if (scope != pendingScope) {
|
||||
return _migrateLegacy(prefs, scope);
|
||||
@@ -248,13 +258,4 @@ class SpoofingService {
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static String _uuidV4() {
|
||||
final b = List<int>.generate(16, (_) => _rng.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String h(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-'
|
||||
'${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,19 @@ import '../utils/logger.dart';
|
||||
|
||||
typedef PacketHandler = void Function(Packet packet);
|
||||
|
||||
class _PendingRequest {
|
||||
_PendingRequest(this.completer, this.sentAt);
|
||||
|
||||
final Completer<Packet> completer;
|
||||
final DateTime sentAt;
|
||||
}
|
||||
|
||||
/// Роутер входящих пакетов.
|
||||
///
|
||||
/// Ответы на запросы матчатся по seq (через [registerPending]),
|
||||
/// пуши — по opcode (через [registerHandler]).
|
||||
class PacketDispatcher {
|
||||
final Map<int, Completer<Packet>> _pendingRequests = {};
|
||||
final Map<int, DateTime> _requestTimestamps = {};
|
||||
final Map<int, _PendingRequest> _pendingRequests = {};
|
||||
final Map<int, PacketHandler> _pushHandlers = {};
|
||||
|
||||
final _pushController = StreamController<Packet>.broadcast();
|
||||
@@ -46,14 +52,13 @@ class PacketDispatcher {
|
||||
/// придёт пакет с совпадающим seq.
|
||||
Future<Packet> registerPending(int seq) {
|
||||
final existing = _pendingRequests[seq];
|
||||
if (existing != null && !existing.isCompleted) {
|
||||
existing.completeError(
|
||||
if (existing != null && !existing.completer.isCompleted) {
|
||||
existing.completer.completeError(
|
||||
StateError('seq=$seq переиспользован до получения ответа'),
|
||||
);
|
||||
}
|
||||
final completer = Completer<Packet>();
|
||||
_pendingRequests[seq] = completer;
|
||||
_requestTimestamps[seq] = DateTime.now();
|
||||
_pendingRequests[seq] = _PendingRequest(completer, DateTime.now());
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@@ -80,7 +85,8 @@ class PacketDispatcher {
|
||||
);
|
||||
|
||||
if (packet.isError) {
|
||||
final isSessionExpired = packet.payload is Map &&
|
||||
final isSessionExpired =
|
||||
packet.payload is Map &&
|
||||
packet.payload['message'] == 'FAIL_LOGIN_TOKEN';
|
||||
final serverText = _serverErrorText(packet.payload);
|
||||
if (serverText != null && !isSessionExpired) {
|
||||
@@ -88,8 +94,8 @@ class PacketDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
final completer = _pendingRequests.remove(packet.seq);
|
||||
_requestTimestamps.remove(packet.seq);
|
||||
final pending = _pendingRequests.remove(packet.seq);
|
||||
final completer = pending?.completer;
|
||||
|
||||
if (completer == null) {
|
||||
if (packet.opcode != Opcode.ping) {
|
||||
@@ -116,7 +122,14 @@ class PacketDispatcher {
|
||||
logger.i(
|
||||
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}',
|
||||
);
|
||||
_pushHandlers[packet.opcode]?.call(packet);
|
||||
final handler = _pushHandlers[packet.opcode];
|
||||
if (handler != null) {
|
||||
try {
|
||||
handler(packet);
|
||||
} catch (e) {
|
||||
logger.w('$tag handler failed: $e');
|
||||
}
|
||||
}
|
||||
_pushController.add(packet);
|
||||
}
|
||||
}
|
||||
@@ -126,13 +139,13 @@ class PacketDispatcher {
|
||||
final now = DateTime.now();
|
||||
final staleKeys = <int>[];
|
||||
|
||||
_requestTimestamps.forEach((seq, ts) {
|
||||
if (now.difference(ts).inSeconds > 30) staleKeys.add(seq);
|
||||
_pendingRequests.forEach((seq, pending) {
|
||||
if (now.difference(pending.sentAt).inSeconds > 30) staleKeys.add(seq);
|
||||
});
|
||||
|
||||
for (final seq in staleKeys) {
|
||||
final completer = _pendingRequests.remove(seq);
|
||||
_requestTimestamps.remove(seq);
|
||||
final pending = _pendingRequests.remove(seq);
|
||||
final completer = pending?.completer;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError(TimeoutException('Таймаут запроса seq=$seq'));
|
||||
}
|
||||
@@ -142,12 +155,11 @@ class PacketDispatcher {
|
||||
/// Обрывает все ожидающие запросы (при дисконнекте)
|
||||
void clearPending() {
|
||||
for (final entry in _pendingRequests.entries) {
|
||||
if (!entry.value.isCompleted) {
|
||||
entry.value.completeError(StateError('Соединение закрыто'));
|
||||
if (!entry.value.completer.isCompleted) {
|
||||
entry.value.completer.completeError(StateError('Соединение закрыто'));
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
_requestTimestamps.clear();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
||||
@@ -93,9 +93,7 @@ class ProxyConnector {
|
||||
throw SocketException('SOCKS5: неверная версия в ответе');
|
||||
}
|
||||
if (reply[1] != 0x00) {
|
||||
throw SocketException(
|
||||
'SOCKS5: ошибка подключения, код: ${reply[1]}',
|
||||
);
|
||||
throw SocketException('SOCKS5: ошибка подключения, код: ${reply[1]}');
|
||||
}
|
||||
|
||||
// Пропускаем bind address
|
||||
@@ -125,10 +123,7 @@ class ProxyConnector {
|
||||
|
||||
// ── HTTP CONNECT ────────────────────────────────────────────────────────
|
||||
|
||||
Future<Socket> _connectHttpConnect(
|
||||
String targetHost,
|
||||
int targetPort,
|
||||
) async {
|
||||
Future<Socket> _connectHttpConnect(String targetHost, int targetPort) async {
|
||||
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
||||
logger.i(
|
||||
'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}',
|
||||
@@ -177,9 +172,7 @@ class ProxyConnector {
|
||||
}
|
||||
final statusCode = int.tryParse(parts[1]) ?? 0;
|
||||
if (statusCode != 200) {
|
||||
throw SocketException(
|
||||
'HTTP CONNECT: прокси вернул статус $statusCode',
|
||||
);
|
||||
throw SocketException('HTTP CONNECT: прокси вернул статус $statusCode');
|
||||
}
|
||||
|
||||
logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен');
|
||||
@@ -197,10 +190,7 @@ class ProxyConnector {
|
||||
) async {
|
||||
ServerSocket? server;
|
||||
try {
|
||||
server = await ServerSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
} catch (e) {
|
||||
io.dispose();
|
||||
proxySocket.close();
|
||||
@@ -223,11 +213,13 @@ class ProxyConnector {
|
||||
|
||||
serverSide.listen(
|
||||
(data) {
|
||||
unawaited(io.write(data).catchError((Object _) {
|
||||
try {
|
||||
serverSide.destroy();
|
||||
} catch (_) {}
|
||||
}));
|
||||
unawaited(
|
||||
io.write(data).catchError((Object _) {
|
||||
try {
|
||||
serverSide.destroy();
|
||||
} catch (_) {}
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: (Object _) {
|
||||
proxySocket.shutdown(SocketDirection.send);
|
||||
@@ -303,9 +295,7 @@ class _RawSocketIO {
|
||||
case RawSocketEvent.closed:
|
||||
_closed = true;
|
||||
onClosed?.call();
|
||||
_readWaiter?.completeError(
|
||||
SocketException('Прокси закрыл соединение'),
|
||||
);
|
||||
_readWaiter?.completeError(SocketException('Прокси закрыл соединение'));
|
||||
_readWaiter = null;
|
||||
_writeWaiter?.completeError(
|
||||
SocketException('Прокси закрыл соединение'),
|
||||
@@ -328,8 +318,7 @@ class _RawSocketIO {
|
||||
_readWaiter = Completer<void>();
|
||||
await _readWaiter!.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () =>
|
||||
throw SocketException('Тайм-аут при чтении от прокси'),
|
||||
onTimeout: () => throw SocketException('Тайм-аут при чтении от прокси'),
|
||||
);
|
||||
}
|
||||
final result = Uint8List.fromList(_readBuffer.sublist(0, count));
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../protocol/packet.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Буфер входящих данных.
|
||||
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
|
||||
class ReceiverOverflowException implements Exception {
|
||||
final int size;
|
||||
const ReceiverOverflowException(this.size);
|
||||
@override
|
||||
String toString() => 'PacketReceiver: переполнение буфера ($size B)';
|
||||
}
|
||||
|
||||
class PacketReceiver {
|
||||
Uint8List _buffer = Uint8List(0);
|
||||
int _start = 0;
|
||||
int _end = 0;
|
||||
|
||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||
static const int _maxBufferSize = 2 * 1024 * 1024;
|
||||
|
||||
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
|
||||
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
|
||||
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
|
||||
///
|
||||
/// Накопление идёт без перекопирования всего буфера на каждый чанк: целые
|
||||
/// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается
|
||||
/// сдвигом указателя `_start`, а не пересборкой буфера.
|
||||
List<Uint8List> feed(Uint8List data) {
|
||||
_append(data);
|
||||
|
||||
if (_end - _start > _maxBufferSize) {
|
||||
logger.e(
|
||||
'PacketReceiver: переполнение буфера (${_end - _start} B), сброс',
|
||||
);
|
||||
final overflow = _end - _start;
|
||||
reset();
|
||||
return const [];
|
||||
throw ReceiverOverflowException(overflow);
|
||||
}
|
||||
|
||||
final packets = <Uint8List>[];
|
||||
|
||||
@@ -37,8 +37,9 @@ class VpnBypassService {
|
||||
|
||||
static const String prefKey = 'dev_vpn_bypass';
|
||||
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('ru.komet.app/vpn_bypass');
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'ru.komet.app/vpn_bypass',
|
||||
);
|
||||
|
||||
bool _bound = false;
|
||||
|
||||
@@ -70,8 +71,9 @@ class VpnBypassService {
|
||||
Future<VpnBypassResult> bind() async {
|
||||
VpnBypassResult result;
|
||||
try {
|
||||
final res = await _channel
|
||||
.invokeMapMethod<String, dynamic>('bindToNonVpnNetwork');
|
||||
final res = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'bindToNonVpnNetwork',
|
||||
);
|
||||
final bound = res?['bound'] == true;
|
||||
_bound = bound;
|
||||
result = VpnBypassResult(
|
||||
@@ -97,8 +99,10 @@ class VpnBypassService {
|
||||
);
|
||||
}
|
||||
if (result.bound) {
|
||||
logger.i('VPN bypass: привязано к ${result.boundInterface} '
|
||||
'(${result.transport})');
|
||||
logger.i(
|
||||
'VPN bypass: привязано к ${result.boundInterface} '
|
||||
'(${result.transport})',
|
||||
);
|
||||
} else {
|
||||
logger.w('VPN bypass: обойти не удалось (${result.reason})');
|
||||
}
|
||||
@@ -108,8 +112,9 @@ class VpnBypassService {
|
||||
|
||||
Future<bool> _isVpnActive() async {
|
||||
try {
|
||||
final res = await _channel
|
||||
.invokeMapMethod<String, dynamic>('detectInterfaces');
|
||||
final res = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'detectInterfaces',
|
||||
);
|
||||
if (res != null) {
|
||||
if (res['hasTun'] == true || res['hasVpn'] == true) return true;
|
||||
if (res.containsKey('hasTun')) return false;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dart:async';
|
||||
|
||||
class Debouncer {
|
||||
Debouncer(this.duration);
|
||||
|
||||
final Duration duration;
|
||||
Timer? _timer;
|
||||
|
||||
void run(void Function() action) {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(duration, action);
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void dispose() => cancel();
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../protocol/opcode_map.dart';
|
||||
import 'log_redact.dart';
|
||||
|
||||
class _LogEntry {
|
||||
final int opcode;
|
||||
@@ -132,7 +132,7 @@ class DebugSessionLog {
|
||||
opcode: opcode,
|
||||
seq: seq,
|
||||
requestTime: DateTime.now(),
|
||||
request: _redact(payload),
|
||||
request: redactForLog(payload),
|
||||
),
|
||||
);
|
||||
if (_entries.length > _maxEntriesPerSession) {
|
||||
@@ -147,7 +147,7 @@ class DebugSessionLog {
|
||||
if (entry == null) return;
|
||||
entry.responseTime = DateTime.now();
|
||||
entry.cmd = cmd;
|
||||
entry.response = _redact(payload);
|
||||
entry.response = redactForLog(payload);
|
||||
_scheduleFlush();
|
||||
}
|
||||
|
||||
@@ -270,7 +270,9 @@ class DebugSessionLog {
|
||||
for (var s = 0; s < lastN.length; s++) {
|
||||
final session = lastN[s];
|
||||
buffer.writeln('==================================================');
|
||||
buffer.writeln('ЗАХОД #${s + 1} — ${session.startedAt.toIso8601String()}');
|
||||
buffer.writeln(
|
||||
'ЗАХОД #${s + 1} — ${session.startedAt.toIso8601String()}',
|
||||
);
|
||||
buffer.writeln(
|
||||
'запросов: ${session.entries.length}'
|
||||
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}',
|
||||
@@ -329,37 +331,3 @@ String _cmdName(int? cmd) {
|
||||
return 'cmd$cmd';
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTokenKey(String key) => key.toLowerCase().contains('token');
|
||||
|
||||
bool _isPhoneKey(String key) {
|
||||
final k = key.toLowerCase();
|
||||
return k.contains('phone') || k == 'msisdn';
|
||||
}
|
||||
|
||||
String _maskPhone(dynamic value) {
|
||||
final text = value?.toString() ?? '';
|
||||
if (text.length <= 3) return text;
|
||||
return '${text.substring(0, 3)}***';
|
||||
}
|
||||
|
||||
dynamic _redact(dynamic value) {
|
||||
if (value is Map) {
|
||||
final out = <String, dynamic>{};
|
||||
value.forEach((k, v) {
|
||||
final key = k.toString();
|
||||
if (_isTokenKey(key)) {
|
||||
out[key] = '***';
|
||||
} else if (_isPhoneKey(key)) {
|
||||
out[key] = _maskPhone(v);
|
||||
} else {
|
||||
out[key] = _redact(v);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (value is List) return value.map(_redact).toList();
|
||||
if (value is Uint8List) return '<bytes: ${value.length}>';
|
||||
if (value is num || value is bool || value is String) return value;
|
||||
return value?.toString();
|
||||
}
|
||||
|
||||
+43
-19
@@ -1,4 +1,3 @@
|
||||
/// Shared formatting helpers (dates, durations, sizes, phone, gender).
|
||||
library;
|
||||
|
||||
const List<String> kRuMonthsShort = [
|
||||
@@ -16,9 +15,33 @@ const List<String> kRuMonthsShort = [
|
||||
'дек',
|
||||
];
|
||||
|
||||
String _two(int n) => n.toString().padLeft(2, '0');
|
||||
String pad2(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
String pluralRu(int n, String one, String few, String many) {
|
||||
final mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return many;
|
||||
switch (n % 10) {
|
||||
case 1:
|
||||
return one;
|
||||
case 2:
|
||||
case 3:
|
||||
case 4:
|
||||
return few;
|
||||
default:
|
||||
return many;
|
||||
}
|
||||
}
|
||||
|
||||
String formatVoiceElapsed(int ms) {
|
||||
final totalSec = ms ~/ 1000;
|
||||
final m = totalSec ~/ 60;
|
||||
final s = pad2(totalSec % 60);
|
||||
final ds = (ms % 1000) ~/ 100;
|
||||
return '$m:$s,$ds';
|
||||
}
|
||||
|
||||
final RegExp _phoneNonDigits = RegExp(r'[^0-9]');
|
||||
|
||||
/// "512 Б" / "1.5 КБ" / "3.2 МБ" / "1.1 ГБ" — Cyrillic units, 1 decimal.
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes Б';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
|
||||
@@ -28,38 +51,42 @@ String formatBytes(int bytes) {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} ГБ';
|
||||
}
|
||||
|
||||
/// "m:ss" (e.g. "3:07"); with [padMinutes] the minutes are zero-padded ("03:07").
|
||||
String formatDurationMmSs(Duration d, {bool padMinutes = false}) {
|
||||
final m = d.inMinutes;
|
||||
return '${padMinutes ? _two(m) : m}:${_two(d.inSeconds % 60)}';
|
||||
return '${padMinutes ? pad2(m) : m}:${pad2(d.inSeconds % 60)}';
|
||||
}
|
||||
|
||||
/// "m:ss" from a raw seconds count.
|
||||
String formatSecondsMmSs(int seconds, {bool padMinutes = false}) =>
|
||||
formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes);
|
||||
|
||||
/// "HH:mm" or "HH:mm:ss" when [withSeconds] is set.
|
||||
String formatDurationClock(Duration d) {
|
||||
final s = d.inSeconds;
|
||||
final sec = pad2(s % 60);
|
||||
final m = s ~/ 60;
|
||||
if (m >= 60) return '${m ~/ 60}:${pad2(m % 60)}:$sec';
|
||||
return '$m:$sec';
|
||||
}
|
||||
|
||||
String formatFileStamp(DateTime t) =>
|
||||
'${t.year}${pad2(t.month)}${pad2(t.day)}_'
|
||||
'${pad2(t.hour)}${pad2(t.minute)}${pad2(t.second)}';
|
||||
|
||||
String formatClock(DateTime dt, {bool withSeconds = false}) => withSeconds
|
||||
? '${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}'
|
||||
: '${_two(dt.hour)}:${_two(dt.minute)}';
|
||||
? '${pad2(dt.hour)}:${pad2(dt.minute)}:${pad2(dt.second)}'
|
||||
: '${pad2(dt.hour)}:${pad2(dt.minute)}';
|
||||
|
||||
/// "5 мая 2024".
|
||||
String formatDateWords(DateTime dt) =>
|
||||
'${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}';
|
||||
|
||||
/// "05.04.2024".
|
||||
String formatDateNumeric(DateTime dt) =>
|
||||
'${_two(dt.day)}.${_two(dt.month)}.${dt.year}';
|
||||
'${pad2(dt.day)}.${pad2(dt.month)}.${dt.year}';
|
||||
|
||||
/// "05.04.2024 14:30".
|
||||
String formatDateTimeNumeric(DateTime dt) =>
|
||||
'${formatDateNumeric(dt)} ${formatClock(dt)}';
|
||||
|
||||
/// "5 мая 2024, 14:30".
|
||||
String formatDateTimeWords(DateTime dt) =>
|
||||
'${formatDateWords(dt)}, ${formatClock(dt)}';
|
||||
|
||||
/// "Был(-а) только что / N мин назад / N ч назад / N дн назад / 5 мая 2024".
|
||||
String formatLastSeen(int secondsSinceEpoch) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
|
||||
final diff = DateTime.now().difference(dt);
|
||||
@@ -70,14 +97,12 @@ String formatLastSeen(int secondsSinceEpoch) {
|
||||
return 'Был(-а) ${formatDateWords(dt)}';
|
||||
}
|
||||
|
||||
/// "+7 (912) 345-67-89" for RU numbers, "+digits" otherwise.
|
||||
/// Accepts an int phone or a string; returns null if there is no usable number.
|
||||
String? formatPhone(dynamic raw) {
|
||||
String? digits;
|
||||
if (raw is int && raw > 0) {
|
||||
digits = raw.toString();
|
||||
} else if (raw is String && raw.isNotEmpty && raw != '***') {
|
||||
digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
digits = raw.replaceAll(_phoneNonDigits, '');
|
||||
if (digits.isEmpty) return null;
|
||||
}
|
||||
if (digits == null) return null;
|
||||
@@ -88,7 +113,6 @@ String? formatPhone(dynamic raw) {
|
||||
return '+$digits';
|
||||
}
|
||||
|
||||
/// 1 → "Мужской", 2 → "Женский", anything else → null.
|
||||
String? formatGender(dynamic raw) {
|
||||
if (raw is! int) return null;
|
||||
if (raw == 1) return 'Мужской';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dart:math';
|
||||
|
||||
final Random _rng = Random.secure();
|
||||
|
||||
String randomHex(int bytes) {
|
||||
final sb = StringBuffer();
|
||||
for (var i = 0; i < bytes; i++) {
|
||||
sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String uuidV4() {
|
||||
final b = List<int>.generate(16, (_) => _rng.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String h(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-'
|
||||
'${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}';
|
||||
}
|
||||
@@ -2,13 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
const _redacted = '***';
|
||||
|
||||
const _sensitiveSubstrings = [
|
||||
'password',
|
||||
'token',
|
||||
'phone',
|
||||
'secret',
|
||||
'auth',
|
||||
];
|
||||
const _sensitiveSubstrings = ['password', 'token', 'secret', 'auth'];
|
||||
|
||||
const _sensitiveExact = {
|
||||
'code',
|
||||
@@ -19,7 +13,6 @@ const _sensitiveExact = {
|
||||
'pin',
|
||||
'qrlink',
|
||||
'text',
|
||||
'msisdn',
|
||||
'deviceid',
|
||||
'mt_instanceid',
|
||||
'instanceid',
|
||||
@@ -36,14 +29,33 @@ bool _isSensitiveKey(Object? key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isPhoneKey(Object? key) {
|
||||
if (key is! String) return false;
|
||||
final k = key.toLowerCase();
|
||||
return k.contains('phone') || k == 'msisdn';
|
||||
}
|
||||
|
||||
String _maskPhone(dynamic value) {
|
||||
final text = value?.toString() ?? '';
|
||||
if (text.length <= 3) return text;
|
||||
return '${text.substring(0, 3)}***';
|
||||
}
|
||||
|
||||
dynamic redactForLog(dynamic value) {
|
||||
if (value is Map) {
|
||||
final out = {};
|
||||
value.forEach((k, v) {
|
||||
out[k] = _isSensitiveKey(k) ? _redacted : redactForLog(v);
|
||||
if (_isPhoneKey(k)) {
|
||||
out[k] = _maskPhone(v);
|
||||
} else {
|
||||
out[k] = _isSensitiveKey(k) ? _redacted : redactForLog(v);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (value is Uint8List) {
|
||||
return '<bytes: ${value.length}>';
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(redactForLog).toList();
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ class MediaCache {
|
||||
|
||||
static Directory? _dir;
|
||||
static int? _cachedSize;
|
||||
static final Map<String, Future<File?>> _inFlight = {};
|
||||
|
||||
static Future<Directory> _cacheDir() async {
|
||||
final cached = _dir;
|
||||
if (cached != null) return cached;
|
||||
final base = await getApplicationSupportDirectory();
|
||||
final dir = Directory(p.join(base.path, 'media_cache${AppInstance.suffix}'));
|
||||
final dir = Directory(
|
||||
p.join(base.path, 'media_cache${AppInstance.suffix}'),
|
||||
);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
@@ -63,6 +66,23 @@ class MediaCache {
|
||||
final existingFile = await existing(name);
|
||||
if (existingFile != null) return existingFile;
|
||||
|
||||
final running = _inFlight[name];
|
||||
if (running != null) return running;
|
||||
|
||||
final future = _download(name, url, onProgress);
|
||||
_inFlight[name] = future;
|
||||
try {
|
||||
return await future;
|
||||
} finally {
|
||||
_inFlight.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<File?> _download(
|
||||
String name,
|
||||
String url,
|
||||
void Function(double progress)? onProgress,
|
||||
) async {
|
||||
final file = await fileFor(name);
|
||||
final part = File('${file.path}.part');
|
||||
final client = HttpClient();
|
||||
@@ -166,8 +186,9 @@ class MediaCache {
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) =>
|
||||
a.statSync().modified.compareTo(b.statSync().modified));
|
||||
files.sort(
|
||||
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
|
||||
);
|
||||
|
||||
for (final file in files) {
|
||||
if (total <= limit) break;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
String displayName(Object? first, Object? last, {String fallback = ''}) {
|
||||
final f = first?.toString().trim() ?? '';
|
||||
final l = last?.toString().trim() ?? '';
|
||||
final full = [f, l].where((s) => s.isNotEmpty).join(' ');
|
||||
return full.isEmpty ? fallback : full;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
int? parseIntOrNull(Object? v) {
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
List<int> parseIntList(Object? v) =>
|
||||
v is List ? v.map(parseIntOrNull).whereType<int>().toList() : const <int>[];
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../../core/cache/info_cache.dart';
|
||||
import '../../core/utils/format.dart';
|
||||
import '../../models/contact_info.dart';
|
||||
import 'slash_command.dart';
|
||||
|
||||
Future<void> runInfo(CommandContext ctx) async {
|
||||
@@ -26,30 +27,19 @@ Future<void> runInfo(CommandContext ctx) async {
|
||||
);
|
||||
}
|
||||
|
||||
String _summary(Map<String, dynamic> c, int targetId) {
|
||||
final flags = (c['options'] as List?)?.whereType<String>().toList() ?? const [];
|
||||
final region = (c['country'] as String?)?.trim();
|
||||
String _summary(ContactInfo c, int targetId) {
|
||||
final flags = c.options;
|
||||
final region = (c.raw['country'] as String?)?.trim();
|
||||
|
||||
return 'Никнейм: ${_nick(c)}\n'
|
||||
'Дата регистрации: ${_date(c['registrationTime'])}\n'
|
||||
'Дата последнего изменения профиля: ${_date(c['updateTime'])}\n'
|
||||
'id: ${c['id'] ?? targetId}\n'
|
||||
return 'Никнейм: ${c.displayName ?? '—'}\n'
|
||||
'Дата регистрации: ${_date(c.raw['registrationTime'])}\n'
|
||||
'Дата последнего изменения профиля: ${_date(c.raw['updateTime'])}\n'
|
||||
'id: ${c.id ?? targetId}\n'
|
||||
'Регион: ${region == null || region.isEmpty ? '—' : region}\n'
|
||||
'Флаги: ${flags.isEmpty ? '—' : flags.join(', ')}\n'
|
||||
'ip: not fetched';
|
||||
}
|
||||
|
||||
String _nick(Map<String, dynamic> c) {
|
||||
final names = c['names'];
|
||||
if (names is List && names.isNotEmpty && names.first is Map) {
|
||||
final n = names.first as Map;
|
||||
final name = (n['name'] as String?) ??
|
||||
'${n['firstName'] ?? ''} ${n['lastName'] ?? ''}'.trim();
|
||||
if (name.isNotEmpty) return name;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
String _date(dynamic ms) {
|
||||
if (ms is! int || ms <= 0) return '—';
|
||||
return formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms));
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../core/utils/format.dart';
|
||||
|
||||
class DebugCacheSection extends StatelessWidget {
|
||||
final int cacheSize;
|
||||
final bool clearingCache;
|
||||
final String cacheLimitLabel;
|
||||
final VoidCallback onPickCacheLimit;
|
||||
final VoidCallback onClearCache;
|
||||
|
||||
const DebugCacheSection({
|
||||
super.key,
|
||||
required this.cacheSize,
|
||||
required this.clearingCache,
|
||||
required this.cacheLimitLabel,
|
||||
required this.onPickCacheLimit,
|
||||
required this.onClearCache,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onPickCacheLimit,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.data_usage,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Лимит кэша медиа',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
cacheLimitLabel,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.chevron_right,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: clearingCache ? null : onClearCache,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.delete_sweep,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Очистить кэш медиа',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
clearingCache
|
||||
? 'Очистка…'
|
||||
: 'Занято: ${formatBytes(cacheSize)}',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (clearingCache)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/glossy_pill.dart';
|
||||
|
||||
class DebugToggleTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String Function(bool value)? subtitle;
|
||||
final ValueListenable<bool> valueListenable;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const DebugToggleTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.valueListenable,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: valueListenable,
|
||||
builder: (context, value, _) {
|
||||
final resolvedSubtitle = subtitle?.call(value);
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depth: 6,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (resolvedSubtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
resolvedSubtitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../core/config/app_commands.dart';
|
||||
import '../../core/config/app_digital_id_mode.dart';
|
||||
import '../../core/config/app_link_preview.dart';
|
||||
import '../../core/config/app_pranks.dart';
|
||||
import '../../core/config/app_show_extra_info.dart';
|
||||
import '../../core/config/app_stories.dart';
|
||||
import '../../core/config/app_swipe_back_desktop.dart';
|
||||
import '../screens/digital_id/digital_id_web_screen.dart';
|
||||
import '../widgets/custom_notification.dart';
|
||||
import 'debug_toggle_tile.dart';
|
||||
|
||||
class DebugFeatureTogglesSection extends StatelessWidget {
|
||||
const DebugFeatureTogglesSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.swipe_right,
|
||||
title: 'Свайп-назад в десктоп-режиме',
|
||||
subtitle: (_) =>
|
||||
'Включает жест «провести от левого края, чтобы '
|
||||
'закрыть» внутри встроенной панели чата на '
|
||||
'десктопе — для тестирования курсором',
|
||||
valueListenable: AppSwipeBackDesktop.current,
|
||||
onChanged: AppSwipeBackDesktop.save,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.auto_awesome,
|
||||
title: 'Приколь4ики',
|
||||
valueListenable: AppPranks.current,
|
||||
onChanged: AppPranks.save,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.badge,
|
||||
title: 'Нативный Цифровой ID',
|
||||
subtitle: (native) => native
|
||||
? 'Нативный экран (REST ext-api.max.ru)'
|
||||
: 'Оригинальная страница в WebView',
|
||||
valueListenable: AppDigitalIdNative.current,
|
||||
onChanged: AppDigitalIdNative.save,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () async {
|
||||
await resetDigitalIdWebData();
|
||||
if (!context.mounted) return;
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Цифровой ID сброшен — Госуслуги спросят вход заново',
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.restart_alt,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Сбросить Цифровой ID',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Очистить куки и данные WebView',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.amp_stories,
|
||||
title: 'Истории',
|
||||
subtitle: (_) => 'Отображение ленты историй в списке чатов',
|
||||
valueListenable: AppStories.current,
|
||||
onChanged: AppStories.save,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.terminal,
|
||||
title: 'Команды',
|
||||
subtitle: (_) => 'Панель команд по вводу «/» в строке сообщения',
|
||||
valueListenable: AppCommands.current,
|
||||
onChanged: AppCommands.save,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.link,
|
||||
title: 'Предпросмотр ссылок',
|
||||
subtitle: (_) => 'Карточки с превью для ссылок в сообщениях',
|
||||
valueListenable: AppLinkPreview.current,
|
||||
onChanged: AppLinkPreview.save,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.info,
|
||||
title: 'Доп. информация',
|
||||
subtitle: (_) =>
|
||||
'Раздел «Info» в настройках и вкладка с '
|
||||
'технической информацией в профиле собеседника',
|
||||
valueListenable: AppShowExtraInfo.current,
|
||||
onChanged: AppShowExtraInfo.save,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class DebugHeaderSection extends StatelessWidget {
|
||||
const DebugHeaderSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.arrow_back,
|
||||
color: cs.onSurface,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Для разработчиков',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../widgets/custom_notification.dart';
|
||||
import '../widgets/glossy_pill.dart';
|
||||
|
||||
class DebugIdSearchSection extends StatelessWidget {
|
||||
final TextEditingController idController;
|
||||
final bool isSearching;
|
||||
final bool hasSearched;
|
||||
final List<SearchHit> hits;
|
||||
final Map<String, String> errors;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
const DebugIdSearchSection({
|
||||
super.key,
|
||||
required this.idController,
|
||||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.hits,
|
||||
required this.errors,
|
||||
required this.onSearch,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depth: 6,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Поиск по ID',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Параллельно: contactInfo (32) + chatInfo (48) + publicSearch (60)',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: idController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите ID',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => onSearch(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: isSearching ? null : onSearch,
|
||||
child: isSearching
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Symbols.search, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasSearched && !isSearching) ...[
|
||||
const SizedBox(height: 12),
|
||||
if (hits.isEmpty && errors.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Ничего не найдено',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
),
|
||||
for (final hit in hits) ...[
|
||||
_SearchResultCard(hit: hit),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
for (final entry in errors.entries) ...[
|
||||
_ErrorChip(label: entry.key, message: entry.value),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum HitKind { dialog, chat, channel, bot, official, contact, user, unknown }
|
||||
|
||||
class SearchHit {
|
||||
final String source;
|
||||
final int id;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? avatarUrl;
|
||||
final List<HitKind> badges;
|
||||
final bool isChatEntity;
|
||||
|
||||
SearchHit({
|
||||
required this.source,
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.avatarUrl,
|
||||
required this.badges,
|
||||
required this.isChatEntity,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
static SearchHit? fromContact(String source, Map raw) {
|
||||
final id = raw['id'];
|
||||
if (id is! int) return null;
|
||||
final namesRaw = raw['names'];
|
||||
String title = 'User #$id';
|
||||
if (namesRaw is List && namesRaw.isNotEmpty) {
|
||||
final n = namesRaw.first;
|
||||
if (n is Map) {
|
||||
final full = n['name']?.toString();
|
||||
if (full != null && full.isNotEmpty) title = full;
|
||||
}
|
||||
}
|
||||
final opts = (raw['options'] is List)
|
||||
? (raw['options'] as List).whereType<String>().toSet()
|
||||
: <String>{};
|
||||
final badges = <HitKind>[];
|
||||
if (opts.contains('BOT')) badges.add(HitKind.bot);
|
||||
if (opts.contains('OFFICIAL')) badges.add(HitKind.official);
|
||||
if (badges.isEmpty) badges.add(HitKind.contact);
|
||||
return SearchHit(
|
||||
source: source,
|
||||
id: id,
|
||||
title: title,
|
||||
subtitle: (raw['description'] as String?)?.trim().isNotEmpty == true
|
||||
? raw['description'] as String
|
||||
: (raw['phone'] != null ? 'Телефон скрыт' : null),
|
||||
avatarUrl: raw['baseUrl'] as String?,
|
||||
badges: badges,
|
||||
isChatEntity: false,
|
||||
);
|
||||
}
|
||||
|
||||
static SearchHit? fromChat(String source, Map raw) {
|
||||
final id = raw['id'];
|
||||
if (id is! int) return null;
|
||||
final type = (raw['type'] as String?) ?? 'CHAT';
|
||||
final title = (raw['title'] as String?) ?? 'Chat #$id';
|
||||
final pCount = raw['participantsCount'] as int?;
|
||||
final badges = <HitKind>[];
|
||||
switch (type) {
|
||||
case 'DIALOG':
|
||||
badges.add(HitKind.dialog);
|
||||
case 'CHANNEL':
|
||||
badges.add(HitKind.channel);
|
||||
case 'CHAT':
|
||||
badges.add(HitKind.chat);
|
||||
default:
|
||||
badges.add(HitKind.unknown);
|
||||
}
|
||||
final opts = raw['options'];
|
||||
if (opts is Map && opts['OFFICIAL'] == true) {
|
||||
badges.add(HitKind.official);
|
||||
}
|
||||
String? subtitle;
|
||||
if (type == 'CHANNEL') {
|
||||
subtitle = pCount != null ? 'Канал · $pCount подписч.' : 'Канал';
|
||||
} else if (type == 'CHAT') {
|
||||
subtitle = pCount != null ? 'Группа · $pCount участн.' : 'Группа';
|
||||
} else {
|
||||
subtitle = 'Диалог';
|
||||
}
|
||||
return SearchHit(
|
||||
source: source,
|
||||
id: id,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
avatarUrl: raw['baseIconUrl'] as String?,
|
||||
badges: badges,
|
||||
isChatEntity: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchResultCard extends StatelessWidget {
|
||||
final SearchHit hit;
|
||||
const _SearchResultCard({required this.hit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_HitAvatar(hit: hit, cs: cs),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
hit.title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
for (final b in hit.badges) ...[
|
||||
const SizedBox(width: 6),
|
||||
_BadgeChip(kind: b, cs: cs),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (hit.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hit.subtitle!,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'id: ${hit.id}',
|
||||
style: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'via ${hit.source}',
|
||||
style: TextStyle(color: cs.outline, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Скопировать id',
|
||||
icon: Icon(
|
||||
Symbols.content_copy,
|
||||
size: 18,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hit.id.toString()));
|
||||
if (context.mounted) {
|
||||
showCustomNotification(context, 'id скопирован');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HitAvatar extends StatelessWidget {
|
||||
final SearchHit hit;
|
||||
final ColorScheme cs;
|
||||
const _HitAvatar({required this.hit, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const size = 44.0;
|
||||
final url = hit.avatarUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
return ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => _fallback(),
|
||||
errorWidget: (_, _, _) => _fallback(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _fallback();
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
final initial = hit.title.isNotEmpty ? hit.title[0].toUpperCase() : '?';
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgeChip extends StatelessWidget {
|
||||
final HitKind kind;
|
||||
final ColorScheme cs;
|
||||
const _BadgeChip({required this.kind, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String label;
|
||||
Color bg;
|
||||
Color fg;
|
||||
switch (kind) {
|
||||
case HitKind.bot:
|
||||
label = 'Bot';
|
||||
bg = cs.tertiaryContainer;
|
||||
fg = cs.onTertiaryContainer;
|
||||
case HitKind.official:
|
||||
label = '✓';
|
||||
bg = cs.primary;
|
||||
fg = cs.onPrimary;
|
||||
case HitKind.contact:
|
||||
label = 'Контакт';
|
||||
bg = cs.surface;
|
||||
fg = cs.onSurfaceVariant;
|
||||
case HitKind.user:
|
||||
label = 'User';
|
||||
bg = cs.surface;
|
||||
fg = cs.onSurfaceVariant;
|
||||
case HitKind.dialog:
|
||||
label = 'Диалог';
|
||||
bg = cs.secondaryContainer;
|
||||
fg = cs.onSecondaryContainer;
|
||||
case HitKind.chat:
|
||||
label = 'Группа';
|
||||
bg = cs.secondaryContainer;
|
||||
fg = cs.onSecondaryContainer;
|
||||
case HitKind.channel:
|
||||
label = 'Канал';
|
||||
bg = cs.tertiaryContainer;
|
||||
fg = cs.onTertiaryContainer;
|
||||
case HitKind.unknown:
|
||||
label = '?';
|
||||
bg = cs.surface;
|
||||
fg = cs.onSurfaceVariant;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorChip extends StatelessWidget {
|
||||
final String label;
|
||||
final String message;
|
||||
const _ErrorChip({required this.label, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.error_outline, size: 16, color: cs.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$label: $message',
|
||||
style: TextStyle(color: cs.onErrorContainer, fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../screens/profile/traffic_monitor_screen.dart';
|
||||
import '../widgets/connection_status.dart';
|
||||
import 'debug_toggle_tile.dart';
|
||||
|
||||
class DebugNetworkSection extends StatelessWidget {
|
||||
final KometAppState? appState;
|
||||
|
||||
const DebugNetworkSection({super.key, required this.appState});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final state = appState;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: state == null
|
||||
? const SizedBox.shrink()
|
||||
: DebugToggleTile(
|
||||
icon: Symbols.speed,
|
||||
title: 'Оверлей FPS',
|
||||
subtitle: (_) =>
|
||||
'Показ текущего фреймрейта поверх интерфейса',
|
||||
valueListenable: state.fpsOverlayEnabled,
|
||||
onChanged: state.setFpsOverlayEnabled,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: state == null
|
||||
? const SizedBox.shrink()
|
||||
: DebugToggleTile(
|
||||
icon: Symbols.vpn_key_off,
|
||||
title: 'Обход VPN',
|
||||
subtitle: (_) =>
|
||||
'Если обнаружен VPN (tun-интерфейс), '
|
||||
'подключаться напрямую через Wi-Fi или '
|
||||
'моб. сеть в обход туннеля. Только Android',
|
||||
valueListenable: state.vpnBypassEnabled,
|
||||
onChanged: state.setVpnBypassEnabled,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DebugToggleTile(
|
||||
icon: Symbols.wifi_off,
|
||||
title: 'Офлайн (тест)',
|
||||
subtitle: (_) =>
|
||||
'Показать индикаторы соединения во всех '
|
||||
'экранах, не разрывая реальную сессию',
|
||||
valueListenable: debugForceOffline,
|
||||
onChanged: (v) => debugForceOffline.value = v,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: state == null
|
||||
? const SizedBox.shrink()
|
||||
: DebugToggleTile(
|
||||
icon: Symbols.gpp_bad,
|
||||
title: 'Отключить проверку TLS',
|
||||
subtitle: (_) =>
|
||||
'Принимать любой сертификат сервера. '
|
||||
'Только для отладки через MitM-прокси — '
|
||||
'соединение становится уязвимым к '
|
||||
'перехвату трафика',
|
||||
valueListenable: state.tlsInsecureEnabled,
|
||||
onChanged: state.setTlsInsecureEnabled,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const TrafficMonitorScreen()),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.lan,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Монитор трафика',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Реалтайм: домены, опкоды и payload внутри '
|
||||
'сокет-соединения',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.chevron_right,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../screens/calls/call_screen.dart';
|
||||
import '../widgets/glossy_pill.dart';
|
||||
import '../widgets/login_success_screen.dart';
|
||||
|
||||
class DebugPreviewsSection extends StatelessWidget {
|
||||
final bool micSignalOn;
|
||||
final ValueChanged<bool> onMicSignalChanged;
|
||||
|
||||
const DebugPreviewsSection({
|
||||
super.key,
|
||||
required this.micSignalOn,
|
||||
required this.onMicSignalChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () async {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
if (!context.mounted) return;
|
||||
final avatar = await precacheLoginAvatar(
|
||||
context,
|
||||
profile?.baseUrl,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
LoginSuccessScreen(preview: true, avatar: avatar),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.celebration,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'test hello',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Показать приветственную анимацию входа',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.chevron_right,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depth: 6,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Экран звонка',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Превью экранов звонков',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DebugCallButton(
|
||||
label: 'Экран звонка (превью)',
|
||||
icon: Symbols.phone,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const CallScreen(name: 'Кирил Г.'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Сигнал микрофона (тест)',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Шлёт change-media-settings в активный звонок, '
|
||||
'не меняя реальный микрофон',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: micSignalOn, onChanged: onMicSignalChanged),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DebugCallButton extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DebugCallButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../screens/auth/login_screen.dart';
|
||||
|
||||
class DebugQuickActionsSection extends StatelessWidget {
|
||||
final VoidCallback onExportLog;
|
||||
|
||||
const DebugQuickActionsSection({super.key, required this.onExportLog});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onExportLog,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.bug_report,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Отладочный лог',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Все запросы за последние 3 захода в приложение',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.save_alt,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.dialpad,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Экран ввода номера',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Открыть без выхода из аккаунта и обрыва сессии',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.chevron_right,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../main.dart';
|
||||
import '../widgets/glossy_pill.dart';
|
||||
|
||||
class DebugSyncProbeSection extends StatefulWidget {
|
||||
const DebugSyncProbeSection({super.key});
|
||||
|
||||
@override
|
||||
State<DebugSyncProbeSection> createState() => _DebugSyncProbeSectionState();
|
||||
}
|
||||
|
||||
class _DebugSyncProbeSectionState extends State<DebugSyncProbeSection> {
|
||||
final _phoneController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _result;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
final name = _nameController.text.trim();
|
||||
if (phone.isEmpty) {
|
||||
setState(() => _result = 'Введите номер');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_result = null;
|
||||
});
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.sync, {
|
||||
'contactList': {
|
||||
phone: {'firstName': name},
|
||||
},
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_result = _pretty(packet.payload);
|
||||
});
|
||||
} on PacketError catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_result = 'PacketError: ${e.message}';
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_result = 'Ошибка: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _pretty(dynamic payload) {
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
try {
|
||||
return encoder.convert(_jsonSafe(payload));
|
||||
} catch (_) {
|
||||
return payload.toString();
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _jsonSafe(dynamic v) {
|
||||
if (v is Map) {
|
||||
return v.map((k, val) => MapEntry(k.toString(), _jsonSafe(val)));
|
||||
}
|
||||
if (v is List) return v.map(_jsonSafe).toList();
|
||||
if (v is String || v is num || v is bool || v == null) return v;
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depth: 6,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sync contactList (21)',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Резолв контакта по номеру и имени, полный ответ сервера',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: !_loading,
|
||||
decoration: InputDecoration(
|
||||
hintText: '+6282233831826',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
enabled: !_loading,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Имя',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _send,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Отправить'),
|
||||
),
|
||||
if (_result != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SelectableText(
|
||||
_result!,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'password_2fa_screen.dart';
|
||||
import 'registration_screen.dart';
|
||||
import 'session_stale_recovery.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/utils/sms_code_listener.dart';
|
||||
@@ -28,7 +29,7 @@ class CodeConfirmationScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
with TickerProviderStateMixin {
|
||||
with TickerProviderStateMixin, SessionStaleRecovery {
|
||||
final TextEditingController _codeController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final SmsCodeListener _smsListener = SmsCodeListener();
|
||||
@@ -45,21 +46,17 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
AnimationStatusListener? _routeAnimationListener;
|
||||
|
||||
late String _token;
|
||||
late int _epoch;
|
||||
StreamSubscription<SessionState>? _stateSub;
|
||||
bool _recovering = false;
|
||||
bool _verifying = false;
|
||||
bool _dropNotified = false;
|
||||
|
||||
bool get _sessionStale =>
|
||||
api.sessionEpoch != _epoch || api.state != SessionState.online;
|
||||
@override
|
||||
String get connectionDroppedMessage =>
|
||||
'Соединение прервалось, восстанавливаем…';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_token = widget.token;
|
||||
_epoch = api.sessionEpoch;
|
||||
_stateSub = api.stateStream.listen(_onSessionState);
|
||||
startSessionRecovery();
|
||||
_startTimer();
|
||||
_listenForSmsCode();
|
||||
|
||||
@@ -89,7 +86,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
_routeAnimation?.removeStatusListener(_routeAnimationListener!);
|
||||
}
|
||||
_smsListener.dispose();
|
||||
_stateSub?.cancel();
|
||||
stopSessionRecovery();
|
||||
_timer?.cancel();
|
||||
_errorTimer?.cancel();
|
||||
_shakeController.dispose();
|
||||
@@ -98,24 +95,10 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSessionState(SessionState state) {
|
||||
if (!mounted) return;
|
||||
if (state != SessionState.online) {
|
||||
if (!_dropNotified) {
|
||||
_dropNotified = true;
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Соединение прервалось, восстанавливаем…',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (api.sessionEpoch != _epoch) _recoverStaleSession();
|
||||
}
|
||||
|
||||
Future<void> _recoverStaleSession() async {
|
||||
if (_recovering) return;
|
||||
setState(() => _recovering = true);
|
||||
@override
|
||||
Future<void> recoverStaleSession() async {
|
||||
if (recovering) return;
|
||||
setState(() => recovering = true);
|
||||
try {
|
||||
if (api.state != SessionState.online) {
|
||||
final back = await api.stateStream
|
||||
@@ -135,8 +118,8 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_token = fresh.token;
|
||||
_epoch = api.sessionEpoch;
|
||||
_dropNotified = false;
|
||||
sessionEpoch = api.sessionEpoch;
|
||||
dropNotified = false;
|
||||
_codeController.clear();
|
||||
_errorMessage = null;
|
||||
});
|
||||
@@ -151,7 +134,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
showCustomNotification(context, 'Не удалось обновить код: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _recovering = false);
|
||||
if (mounted) setState(() => recovering = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +190,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
}
|
||||
|
||||
void _applyAutoCode(String code) {
|
||||
if (_verifying || _recovering) return;
|
||||
if (_verifying || recovering) return;
|
||||
if (_codeController.text == code) return;
|
||||
_codeController.text = code;
|
||||
_codeController.selection = TextSelection.collapsed(offset: code.length);
|
||||
@@ -232,10 +215,10 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
}
|
||||
|
||||
Future<void> _verifyCode() async {
|
||||
if (_codeController.text.length != 6 || _recovering || _verifying) return;
|
||||
if (_codeController.text.length != 6 || recovering || _verifying) return;
|
||||
|
||||
if (_sessionStale) {
|
||||
_recoverStaleSession();
|
||||
if (sessionStale) {
|
||||
recoverStaleSession();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,8 +291,8 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
if (!verified && (isSessionStateError(e) || _sessionStale)) {
|
||||
_recoverStaleSession();
|
||||
if (!verified && (isSessionStateError(e) || sessionStale)) {
|
||||
recoverStaleSession();
|
||||
} else {
|
||||
_showError(e.toString());
|
||||
}
|
||||
@@ -529,7 +512,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FloatingActionButton(
|
||||
onPressed: (_recovering || _verifying)
|
||||
onPressed: (recovering || _verifying)
|
||||
? null
|
||||
: () {
|
||||
if (_codeController.text.length == 6) _verifyCode();
|
||||
@@ -541,7 +524,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: _recovering
|
||||
child: recovering
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
|
||||
@@ -66,7 +66,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await accountModule.switchAccount(returnId);
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Не удалось переключить аккаунт');
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
|
||||
@@ -151,7 +155,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
String _countryDisplayName(CountryName country) {
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
return lang == 'ru' ? country.ru : country.en;
|
||||
return country.displayName(lang);
|
||||
}
|
||||
|
||||
String _phoneMaskHint(CountryName country) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
import 'session_stale_recovery.dart';
|
||||
|
||||
class Password2FAScreen extends StatefulWidget {
|
||||
final String trackId;
|
||||
@@ -16,60 +15,41 @@ class Password2FAScreen extends StatefulWidget {
|
||||
State<Password2FAScreen> createState() => _Password2FAScreenState();
|
||||
}
|
||||
|
||||
class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
class _Password2FAScreenState extends State<Password2FAScreen>
|
||||
with SessionStaleRecovery {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
bool _isPasswordVisible = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
late int _epoch;
|
||||
StreamSubscription<SessionState>? _stateSub;
|
||||
bool _recovering = false;
|
||||
bool _dropNotified = false;
|
||||
|
||||
bool get _sessionStale =>
|
||||
api.sessionEpoch != _epoch || api.state != SessionState.online;
|
||||
@override
|
||||
String get connectionDroppedMessage => 'Соединение прервалось…';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_epoch = api.sessionEpoch;
|
||||
_stateSub = api.stateStream.listen(_onSessionState);
|
||||
startSessionRecovery();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateSub?.cancel();
|
||||
stopSessionRecovery();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSessionState(SessionState state) {
|
||||
if (!mounted) return;
|
||||
if (state != SessionState.online) {
|
||||
if (!_dropNotified) {
|
||||
_dropNotified = true;
|
||||
showCustomNotification(context, 'Соединение прервалось…');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (api.sessionEpoch != _epoch) _recoverStaleSession();
|
||||
}
|
||||
|
||||
void _recoverStaleSession() {
|
||||
if (_recovering || !mounted) return;
|
||||
_recovering = true;
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Соединение прервалось — войдите заново',
|
||||
);
|
||||
@override
|
||||
void recoverStaleSession() {
|
||||
if (recovering || !mounted) return;
|
||||
recovering = true;
|
||||
showCustomNotification(context, 'Соединение прервалось — войдите заново');
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _checkPassword() async {
|
||||
if (_passwordController.text.isEmpty || _isLoading || _recovering) return;
|
||||
if (_passwordController.text.isEmpty || _isLoading || recovering) return;
|
||||
|
||||
if (_sessionStale) {
|
||||
_recoverStaleSession();
|
||||
if (sessionStale) {
|
||||
recoverStaleSession();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,8 +95,8 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
if (!passed && (isSessionStateError(e) || _sessionStale)) {
|
||||
_recoverStaleSession();
|
||||
if (!passed && (isSessionStateError(e) || sessionStale)) {
|
||||
recoverStaleSession();
|
||||
} else {
|
||||
showCustomNotification(context, 'Неверный пароль: $e');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/labeled_settings_field.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
class ProxySettingsSheet extends StatefulWidget {
|
||||
@@ -147,40 +148,40 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
child: isActive
|
||||
? Column(
|
||||
children: [
|
||||
_buildTextField(
|
||||
LabeledSettingsField(
|
||||
controller: _hostController,
|
||||
label: l10n.proxyHostLabel,
|
||||
hintText: '127.0.0.1',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.url,
|
||||
enabled: !_busy,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
LabeledSettingsField(
|
||||
controller: _portController,
|
||||
label: l10n.proxyPortLabel,
|
||||
hintText: _selectedType == ProxyType.socks5
|
||||
? '1080'
|
||||
: '8080',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
enabled: !_busy,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
LabeledSettingsField(
|
||||
controller: _usernameController,
|
||||
label: l10n.proxyUsernameLabel,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.text,
|
||||
enabled: !_busy,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
LabeledSettingsField(
|
||||
controller: _passwordController,
|
||||
label: l10n.proxyPasswordLabel,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
enabled: !_busy,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
@@ -242,54 +243,4 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required ColorScheme cs,
|
||||
String? hintText,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
bool obscureText = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: !_busy,
|
||||
obscureText: obscureText,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 15,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,7 @@ class _RegistrationScreenState extends State<RegistrationScreen> {
|
||||
? cs.primaryContainer
|
||||
: cs.surfaceContainerHighest,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)),
|
||||
child: _isSubmitting
|
||||
? SizedBox(
|
||||
width: 22,
|
||||
@@ -122,9 +120,7 @@ class _RegistrationScreenState extends State<RegistrationScreen> {
|
||||
)
|
||||
: Icon(
|
||||
Icons.arrow_forward,
|
||||
color: _canSubmit
|
||||
? cs.onPrimaryContainer
|
||||
: cs.onSurfaceVariant,
|
||||
color: _canSubmit ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -306,12 +302,10 @@ class _RegistrationScreenState extends State<RegistrationScreen> {
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: avatar.url,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: cs.surfaceContainerHigh,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: cs.surfaceContainerHigh,
|
||||
),
|
||||
placeholder: (_, __) =>
|
||||
Container(color: cs.surfaceContainerHigh),
|
||||
errorWidget: (_, __, ___) =>
|
||||
Container(color: cs.surfaceContainerHigh),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,15 +17,29 @@ class SelectCountryScreen extends StatefulWidget {
|
||||
State<SelectCountryScreen> createState() => _SelectCountryScreenState();
|
||||
}
|
||||
|
||||
class _CountrySearchEntry {
|
||||
final CountryName country;
|
||||
final String ruLower;
|
||||
final String enLower;
|
||||
|
||||
const _CountrySearchEntry(this.country, this.ruLower, this.enLower);
|
||||
}
|
||||
|
||||
class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
bool _isSearching = false;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late List<CountryName> _filteredCountries;
|
||||
late final List<_CountrySearchEntry> _searchEntries;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filteredCountries = widget.countries;
|
||||
_searchEntries = widget.countries
|
||||
.map(
|
||||
(c) => _CountrySearchEntry(c, c.ru.toLowerCase(), c.en.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -40,11 +54,15 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
_filteredCountries = widget.countries;
|
||||
} else {
|
||||
final q = query.toLowerCase();
|
||||
_filteredCountries = widget.countries.where((c) {
|
||||
return c.ru.toLowerCase().contains(q) ||
|
||||
c.en.toLowerCase().contains(q) ||
|
||||
c.phoneCode.contains(q);
|
||||
}).toList();
|
||||
_filteredCountries = _searchEntries
|
||||
.where(
|
||||
(e) =>
|
||||
e.ruLower.contains(q) ||
|
||||
e.enLower.contains(q) ||
|
||||
e.country.phoneCode.contains(q),
|
||||
)
|
||||
.map((e) => e.country)
|
||||
.toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -127,7 +145,7 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
lang == 'ru' ? country.ru : country.en,
|
||||
country.displayName(lang),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/labeled_settings_field.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
class ServerSettingsSheet extends StatefulWidget {
|
||||
@@ -137,21 +138,21 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTextField(
|
||||
LabeledSettingsField(
|
||||
controller: _hostController,
|
||||
label: l10n.serverHostLabel,
|
||||
hintText: ServerConfig.defaultHost,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.url,
|
||||
enabled: !_busy,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
LabeledSettingsField(
|
||||
controller: _portController,
|
||||
label: l10n.serverPortLabel,
|
||||
hintText: '${ServerConfig.defaultPort}',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
enabled: !_busy,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
@@ -169,52 +170,4 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required ColorScheme cs,
|
||||
String? hintText,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: !_busy,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 15,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
mixin SessionStaleRecovery<T extends StatefulWidget> on State<T> {
|
||||
int sessionEpoch = 0;
|
||||
bool recovering = false;
|
||||
bool dropNotified = false;
|
||||
StreamSubscription<SessionState>? _stateSub;
|
||||
|
||||
bool get sessionStale =>
|
||||
api.sessionEpoch != sessionEpoch || api.state != SessionState.online;
|
||||
|
||||
String get connectionDroppedMessage;
|
||||
|
||||
void recoverStaleSession();
|
||||
|
||||
void startSessionRecovery() {
|
||||
sessionEpoch = api.sessionEpoch;
|
||||
_stateSub = api.stateStream.listen(_onSessionState);
|
||||
}
|
||||
|
||||
void stopSessionRecovery() {
|
||||
_stateSub?.cancel();
|
||||
}
|
||||
|
||||
void _onSessionState(SessionState state) {
|
||||
if (!mounted) return;
|
||||
if (state != SessionState.online) {
|
||||
if (!dropNotified) {
|
||||
dropNotified = true;
|
||||
showCustomNotification(context, connectionDroppedMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (api.sessionEpoch != sessionEpoch) recoverStaleSession();
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,8 @@ class _TokenLoginScreenState extends State<TokenLoginScreen> {
|
||||
deviceType: _selectedDeviceType,
|
||||
arch: _selectedArch,
|
||||
appVersion: _appVersionController.text.trim(),
|
||||
buildNumber: int.tryParse(_buildNumberController.text.trim()) ??
|
||||
buildNumber:
|
||||
int.tryParse(_buildNumberController.text.trim()) ??
|
||||
SpoofingService.hardcodedBuildNumber,
|
||||
pushDeviceType: _pushDeviceTypeController.text.trim(),
|
||||
instanceId: _instanceIdController.text.trim(),
|
||||
@@ -214,45 +215,81 @@ class _TokenLoginScreenState extends State<TokenLoginScreen> {
|
||||
(v) => setState(() => _selectedDeviceType = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_deviceNameController, l10n.spoofFieldDeviceName,
|
||||
Symbols.smartphone),
|
||||
_field(
|
||||
_deviceNameController,
|
||||
l10n.spoofFieldDeviceName,
|
||||
Symbols.smartphone,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_osVersionController, l10n.spoofFieldOsVersion,
|
||||
Symbols.layers),
|
||||
_field(
|
||||
_osVersionController,
|
||||
l10n.spoofFieldOsVersion,
|
||||
Symbols.layers,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_screenController, l10n.spoofFieldScreen, Symbols.fullscreen),
|
||||
_field(
|
||||
_screenController,
|
||||
l10n.spoofFieldScreen,
|
||||
Symbols.fullscreen,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_timezoneController, l10n.spoofFieldTimezone, Symbols.public),
|
||||
_field(
|
||||
_timezoneController,
|
||||
l10n.spoofFieldTimezone,
|
||||
Symbols.public,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_localeController, l10n.spoofFieldLocale, Symbols.language),
|
||||
const SizedBox(height: 16),
|
||||
_field(_deviceLocaleController, l10n.spoofFieldDeviceLocale,
|
||||
Symbols.translate),
|
||||
_field(
|
||||
_deviceLocaleController,
|
||||
l10n.spoofFieldDeviceLocale,
|
||||
Symbols.translate,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SectionHeader(
|
||||
l10n.spoofIdentifiersSectionTitle,
|
||||
padding: const EdgeInsets.only(bottom: 16, top: 4),
|
||||
fontSize: 20,
|
||||
),
|
||||
_field(_deviceIdController, l10n.spoofFieldDeviceId, Symbols.tag,
|
||||
onChanged: true),
|
||||
_field(
|
||||
_deviceIdController,
|
||||
l10n.spoofFieldDeviceId,
|
||||
Symbols.tag,
|
||||
onChanged: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_instanceIdController, l10n.spoofFieldInstanceId,
|
||||
Symbols.fingerprint),
|
||||
_field(
|
||||
_instanceIdController,
|
||||
l10n.spoofFieldInstanceId,
|
||||
Symbols.fingerprint,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_clientSessionIdController, l10n.spoofFieldClientSessionId,
|
||||
Symbols.vpn_key,
|
||||
number: true),
|
||||
_field(
|
||||
_clientSessionIdController,
|
||||
l10n.spoofFieldClientSessionId,
|
||||
Symbols.vpn_key,
|
||||
number: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_appVersionController, l10n.spoofFieldAppVersion,
|
||||
Symbols.info),
|
||||
_field(
|
||||
_appVersionController,
|
||||
l10n.spoofFieldAppVersion,
|
||||
Symbols.info,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_buildNumberController, l10n.spoofFieldBuildNumber,
|
||||
Symbols.numbers,
|
||||
number: true),
|
||||
_field(
|
||||
_buildNumberController,
|
||||
l10n.spoofFieldBuildNumber,
|
||||
Symbols.numbers,
|
||||
number: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(_pushDeviceTypeController, l10n.spoofFieldPushDeviceType,
|
||||
Symbols.notifications),
|
||||
_field(
|
||||
_pushDeviceTypeController,
|
||||
l10n.spoofFieldPushDeviceType,
|
||||
Symbols.notifications,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.spoofFieldArchitecture),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -20,8 +20,10 @@ import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/calls/call_info.dart';
|
||||
import '../../../core/calls/call_session.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import 'komet_hub.dart';
|
||||
|
||||
const Color _kEndRed = Color(0xFFE5484D);
|
||||
@@ -49,8 +51,7 @@ class CallScreen extends StatefulWidget {
|
||||
State<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends State<CallScreen>
|
||||
with TickerProviderStateMixin {
|
||||
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
CallSession? _session;
|
||||
StreamSubscription<CallSessionState>? _stateSub;
|
||||
StreamSubscription<void>? _canceledSub;
|
||||
@@ -82,8 +83,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
|
||||
final Map<int, _PeerInfo> _peerInfo = {};
|
||||
|
||||
bool get _isGroup =>
|
||||
widget.isGroup || (_session?.participantCount ?? 0) > 2;
|
||||
bool get _isGroup => widget.isGroup || (_session?.participantCount ?? 0) > 2;
|
||||
|
||||
bool get _tileVideoReady {
|
||||
if (_session?.topology == 'SERVER') return false;
|
||||
@@ -133,8 +133,8 @@ class _CallScreenState extends State<CallScreen>
|
||||
if (name == null || avatar == null) {
|
||||
final info = await ContactInfoFetch.get(id);
|
||||
if (info != null) {
|
||||
name ??= _contactName(info);
|
||||
avatar ??= info['baseUrl'] as String?;
|
||||
name ??= info.displayName;
|
||||
avatar ??= info.avatarUrl;
|
||||
if (name != null) ContactCache.put(id, name);
|
||||
ContactCache.putAvatar(id, avatar);
|
||||
}
|
||||
@@ -146,25 +146,6 @@ class _CallScreenState extends State<CallScreen>
|
||||
});
|
||||
}
|
||||
|
||||
String? _contactName(Map<String, dynamic> info) {
|
||||
final names = info['names'];
|
||||
if (names is! List) return null;
|
||||
Map? pick;
|
||||
for (final n in names) {
|
||||
if (n is! Map) continue;
|
||||
pick ??= n;
|
||||
if (n['type'] == 'ONEME') {
|
||||
pick = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pick == null) return null;
|
||||
final first = (pick['firstName'] as String?) ?? '';
|
||||
final last = pick['lastName'] as String?;
|
||||
final full = (last != null && last.isNotEmpty) ? '$first $last' : first;
|
||||
return full.trim().isEmpty ? null : full.trim();
|
||||
}
|
||||
|
||||
Future<void> _initRenderer() async {
|
||||
await _remoteRenderer.initialize();
|
||||
await _localRenderer.initialize();
|
||||
@@ -243,7 +224,8 @@ class _CallScreenState extends State<CallScreen>
|
||||
|
||||
void _showKometBadge() {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Этот человек использует Komet! :3');
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showCustomNotification(context, l10n.callKometDetectedNotification);
|
||||
}
|
||||
|
||||
void _onChatMessage(CallChatMessage message) {
|
||||
@@ -276,8 +258,8 @@ class _CallScreenState extends State<CallScreen>
|
||||
if (name == null) {
|
||||
final info = await ContactInfoFetch.get(id);
|
||||
if (info != null) {
|
||||
name = _contactName(info);
|
||||
avatar ??= info['baseUrl'] as String?;
|
||||
name = info.displayName;
|
||||
avatar ??= info.avatarUrl;
|
||||
if (name != null) ContactCache.put(id, name);
|
||||
ContactCache.putAvatar(id, avatar);
|
||||
}
|
||||
@@ -398,9 +380,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(context).copyWith(colorScheme: cs),
|
||||
child: _CallInfoSheet(
|
||||
@@ -489,6 +469,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _buildGroupBody(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final participants = _session?.participants ?? const <CallParticipant>[];
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
@@ -499,7 +480,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: participants.isEmpty
|
||||
? Center(child: _statusWithDots(cs, 'Соединение'))
|
||||
? Center(child: _statusWithDots(cs, l10n.callStatusConnecting))
|
||||
: _participantGrid(cs, participants),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -511,13 +492,15 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _groupHeader(ColorScheme cs, int count) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final String subtitle;
|
||||
if (count == 0) {
|
||||
subtitle = 'Соединение…';
|
||||
subtitle = l10n.callGroupConnecting;
|
||||
} else if (count <= 1) {
|
||||
subtitle = 'Ожидание участников…';
|
||||
subtitle = l10n.callGroupWaitingParticipants;
|
||||
} else {
|
||||
subtitle = _participantsLabel(count);
|
||||
subtitle =
|
||||
'$count ${pluralRu(count, 'участник', 'участника', 'участников')}';
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
@@ -549,8 +532,8 @@ class _CallScreenState extends State<CallScreen>
|
||||
final cols = ps.length <= 1
|
||||
? 1
|
||||
: ps.length <= 4
|
||||
? 2
|
||||
: 3;
|
||||
? 2
|
||||
: 3;
|
||||
return GridView.count(
|
||||
crossAxisCount: cols,
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
|
||||
@@ -562,11 +545,14 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _participantTile(ColorScheme cs, CallParticipant p) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final ext = p.externalId;
|
||||
final info = ext != null ? _peerInfo[ext] : null;
|
||||
final name = p.isSelf
|
||||
? 'Вы'
|
||||
: (info?.name?.isNotEmpty == true ? info!.name! : 'Участник');
|
||||
? l10n.callParticipantYou
|
||||
: (info?.name?.isNotEmpty == true
|
||||
? info!.name!
|
||||
: l10n.callParticipantFallback);
|
||||
final url = p.isSelf ? _avatarUrl : info?.avatar;
|
||||
final muted = p.isSelf ? _isMuted : !p.audioEnabled;
|
||||
final speaking = !muted && _session?.isSpeaking(p.id) == true;
|
||||
@@ -577,8 +563,9 @@ class _CallScreenState extends State<CallScreen>
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depth: 6,
|
||||
borderSide:
|
||||
speaking ? const BorderSide(color: _kAcceptGreen, width: 2.5) : null,
|
||||
borderSide: speaking
|
||||
? const BorderSide(color: _kAcceptGreen, width: 2.5)
|
||||
: null,
|
||||
padding: EdgeInsets.all(showVideo ? 0 : 12),
|
||||
child: showVideo
|
||||
? _videoTile(cs, name, muted, p.handRaised, p.screenSharing)
|
||||
@@ -586,8 +573,14 @@ class _CallScreenState extends State<CallScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatarTile(ColorScheme cs, String name, String? url, bool muted,
|
||||
bool hand, bool screen) {
|
||||
Widget _avatarTile(
|
||||
ColorScheme cs,
|
||||
String name,
|
||||
String? url,
|
||||
bool muted,
|
||||
bool hand,
|
||||
bool screen,
|
||||
) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -607,22 +600,34 @@ class _CallScreenState extends State<CallScreen>
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: _tileBadge(cs, Symbols.front_hand,
|
||||
cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||
child: _tileBadge(
|
||||
cs,
|
||||
Symbols.front_hand,
|
||||
cs.tertiaryContainer,
|
||||
cs.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
if (screen)
|
||||
Positioned(
|
||||
top: -2,
|
||||
left: -2,
|
||||
child: _tileBadge(cs, Symbols.screen_share,
|
||||
cs.primaryContainer, cs.onPrimaryContainer),
|
||||
child: _tileBadge(
|
||||
cs,
|
||||
Symbols.screen_share,
|
||||
cs.primaryContainer,
|
||||
cs.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
if (muted)
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
child: _tileBadge(cs, Symbols.mic_off,
|
||||
cs.surfaceContainerHighest, cs.onSurfaceVariant),
|
||||
child: _tileBadge(
|
||||
cs,
|
||||
Symbols.mic_off,
|
||||
cs.surfaceContainerHighest,
|
||||
cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -647,7 +652,12 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _videoTile(
|
||||
ColorScheme cs, String name, bool muted, bool hand, bool screen) {
|
||||
ColorScheme cs,
|
||||
String name,
|
||||
bool muted,
|
||||
bool hand,
|
||||
bool screen,
|
||||
) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Stack(
|
||||
@@ -666,8 +676,12 @@ class _CallScreenState extends State<CallScreen>
|
||||
if (muted)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(Symbols.mic_off,
|
||||
size: 16, color: Colors.white, fill: 1),
|
||||
child: Icon(
|
||||
Symbols.mic_off,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
@@ -689,15 +703,23 @@ class _CallScreenState extends State<CallScreen>
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: _tileBadge(cs, Symbols.front_hand, cs.tertiaryContainer,
|
||||
cs.onTertiaryContainer),
|
||||
child: _tileBadge(
|
||||
cs,
|
||||
Symbols.front_hand,
|
||||
cs.tertiaryContainer,
|
||||
cs.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
if (screen)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: _tileBadge(cs, Symbols.screen_share, cs.primaryContainer,
|
||||
cs.onPrimaryContainer),
|
||||
child: _tileBadge(
|
||||
cs,
|
||||
Symbols.screen_share,
|
||||
cs.primaryContainer,
|
||||
cs.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -716,16 +738,6 @@ class _CallScreenState extends State<CallScreen>
|
||||
);
|
||||
}
|
||||
|
||||
String _participantsLabel(int n) {
|
||||
final mod10 = n % 10;
|
||||
final mod100 = n % 100;
|
||||
if (mod10 == 1 && mod100 != 11) return '$n участник';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||||
return '$n участника';
|
||||
}
|
||||
return '$n участников';
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
ColorScheme cs, {
|
||||
required Widget avatar,
|
||||
@@ -774,7 +786,9 @@ class _CallScreenState extends State<CallScreen>
|
||||
),
|
||||
),
|
||||
if (t > 0.001)
|
||||
IgnorePointer(child: Opacity(opacity: t, child: _videoScrim(cs))),
|
||||
IgnorePointer(
|
||||
child: Opacity(opacity: t, child: _videoScrim(cs)),
|
||||
),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -833,7 +847,9 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _buildTopBar(ColorScheme cs, double t) {
|
||||
final showTimer = t > 0.001 &&
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final showTimer =
|
||||
t > 0.001 &&
|
||||
_session != null &&
|
||||
_state == CallSessionState.active &&
|
||||
_session!.mediaConnected;
|
||||
@@ -845,7 +861,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
tooltip: 'Свернуть',
|
||||
tooltip: l10n.callTooltipMinimize,
|
||||
icon: Icon(
|
||||
Symbols.close_fullscreen,
|
||||
color: cs.onSurface,
|
||||
@@ -863,8 +879,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
if (_session?.peerIsKomet == true)
|
||||
IconButton(
|
||||
onPressed: _openKometHub,
|
||||
tooltip: 'Komet',
|
||||
//TODO: Бля иконку кометы в код дайтtе' мориарти 00. ал.о
|
||||
tooltip: l10n.callTooltipKometHub,
|
||||
icon: Icon(
|
||||
Symbols.auto_awesome,
|
||||
color: cs.primary,
|
||||
@@ -874,7 +889,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showInfoSheet,
|
||||
tooltip: 'О звонке',
|
||||
tooltip: l10n.callInfoTitle,
|
||||
icon: Icon(
|
||||
Symbols.info,
|
||||
color: cs.onSurface,
|
||||
@@ -907,13 +922,13 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget? _peerStateBar(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final session = _session;
|
||||
if (session == null) return null;
|
||||
final pills = <Widget>[
|
||||
if (session.peerMuted)
|
||||
_statePill(cs, Symbols.mic_off, 'Микрофон выключен'),
|
||||
if (session.peerMuted) _statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
|
||||
if (session.peerVideo)
|
||||
_statePill(cs, Symbols.videocam, 'Камера включена'),
|
||||
_statePill(cs, Symbols.videocam, l10n.callPeerCameraOn),
|
||||
];
|
||||
if (pills.isEmpty) return null;
|
||||
return Wrap(
|
||||
@@ -949,12 +964,15 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _buildAvatar(ColorScheme cs) {
|
||||
final avatarSize =
|
||||
(MediaQuery.of(context).size.shortestSide * 0.42).clamp(128.0, 172.0);
|
||||
final avatarSize = (MediaQuery.of(context).size.shortestSide * 0.42).clamp(
|
||||
128.0,
|
||||
172.0,
|
||||
);
|
||||
return _avatarCircle(avatarSize, cs);
|
||||
}
|
||||
|
||||
String get _displayName => _name.isEmpty ? 'Неизвестный' : _name;
|
||||
String get _displayName =>
|
||||
_name.isEmpty ? AppLocalizations.of(context)!.callUnknownName : _name;
|
||||
|
||||
Widget _avatarCircle(double size, ColorScheme cs) =>
|
||||
_circleAvatar(size, cs, name: _displayName, url: _avatarUrl);
|
||||
@@ -1033,11 +1051,12 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _buildStatus(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (!_incomingPending && _state == CallSessionState.active) {
|
||||
final session = _session;
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
if (!session.mediaConnected) {
|
||||
return _statusWithDots(cs, 'Соединение');
|
||||
return _statusWithDots(cs, l10n.callStatusConnecting);
|
||||
}
|
||||
return _ElapsedText(
|
||||
session: session,
|
||||
@@ -1052,7 +1071,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
|
||||
if (_incomingPending) {
|
||||
return Text(
|
||||
'Входящий звонок',
|
||||
l10n.callIncoming,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
);
|
||||
}
|
||||
@@ -1060,13 +1079,13 @@ class _CallScreenState extends State<CallScreen>
|
||||
String text;
|
||||
switch (_state) {
|
||||
case CallSessionState.connecting:
|
||||
text = 'Соединение';
|
||||
text = l10n.callStatusConnecting;
|
||||
case CallSessionState.ringing:
|
||||
text = 'Вызов';
|
||||
text = l10n.callStatusRinging;
|
||||
case CallSessionState.active:
|
||||
text = '';
|
||||
case CallSessionState.ended:
|
||||
text = 'Звонок завершён';
|
||||
text = l10n.callStatusEnded;
|
||||
}
|
||||
|
||||
return _statusWithDots(cs, text);
|
||||
@@ -1076,10 +1095,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
),
|
||||
Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16)),
|
||||
const SizedBox(width: 4),
|
||||
_CallingDots(animation: _dotsController, color: cs.onSurfaceVariant),
|
||||
],
|
||||
@@ -1092,6 +1108,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _incomingControls(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 56),
|
||||
child: Row(
|
||||
@@ -1099,14 +1116,14 @@ class _CallScreenState extends State<CallScreen>
|
||||
children: [
|
||||
_CallButton(
|
||||
icon: Symbols.call_end,
|
||||
label: 'Отклонить',
|
||||
label: l10n.callDecline,
|
||||
background: _kEndRed,
|
||||
foreground: Colors.white,
|
||||
onTap: _decline,
|
||||
),
|
||||
_CallButton(
|
||||
icon: Symbols.call,
|
||||
label: 'Принять',
|
||||
label: l10n.callAccept,
|
||||
background: _kAcceptGreen,
|
||||
foreground: Colors.white,
|
||||
onTap: _accept,
|
||||
@@ -1117,6 +1134,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _activeControls(ColorScheme cs) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final video = _session?.localVideo == true;
|
||||
final screen = _session?.localScreen == true;
|
||||
return Padding(
|
||||
@@ -1126,14 +1144,14 @@ class _CallScreenState extends State<CallScreen>
|
||||
children: [
|
||||
_CallButton(
|
||||
icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down,
|
||||
label: 'Динамик',
|
||||
label: l10n.callSpeaker,
|
||||
background: _isSpeaker ? cs.primary : cs.surfaceContainerHighest,
|
||||
foreground: _isSpeaker ? cs.onPrimary : cs.onSurface,
|
||||
onTap: _toggleSpeaker,
|
||||
),
|
||||
_CallButton(
|
||||
icon: video ? Symbols.videocam : Symbols.videocam_off,
|
||||
label: 'Видео',
|
||||
label: l10n.callVideoLabel,
|
||||
background: video ? cs.primary : cs.surfaceContainerHighest,
|
||||
foreground: video ? cs.onPrimary : cs.onSurface,
|
||||
busy: _videoBusy,
|
||||
@@ -1141,7 +1159,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
),
|
||||
_CallButton(
|
||||
icon: Symbols.screen_share,
|
||||
label: 'Экран',
|
||||
label: l10n.callScreenLabel,
|
||||
background: screen ? cs.primary : cs.surfaceContainerHighest,
|
||||
foreground: screen ? cs.onPrimary : cs.onSurface,
|
||||
busy: _videoBusy,
|
||||
@@ -1149,14 +1167,14 @@ class _CallScreenState extends State<CallScreen>
|
||||
),
|
||||
_CallButton(
|
||||
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
||||
label: _isMuted ? 'Вкл. звук' : 'Выкл. звук',
|
||||
label: _isMuted ? l10n.callUnmute : l10n.callMute,
|
||||
background: _isMuted ? cs.primary : cs.surfaceContainerHighest,
|
||||
foreground: _isMuted ? cs.onPrimary : cs.onSurface,
|
||||
onTap: _toggleMute,
|
||||
),
|
||||
_CallButton(
|
||||
icon: Symbols.call_end,
|
||||
label: 'Завершить',
|
||||
label: l10n.callEndButton,
|
||||
background: _kEndRed,
|
||||
foreground: Colors.white,
|
||||
onTap: _hangup,
|
||||
@@ -1320,6 +1338,7 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final info = session?.info;
|
||||
|
||||
@@ -1328,41 +1347,64 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
if (v != null && v.isNotEmpty) rows.add([k, v]);
|
||||
}
|
||||
|
||||
add('Клиент', _clientLine(info));
|
||||
add('Платформа', info?.peerPlatform);
|
||||
add('Страна', incoming?.country);
|
||||
add(l10n.callInfoClient, _clientLine(info));
|
||||
add(l10n.callInfoPlatform, info?.peerPlatform);
|
||||
add(l10n.callInfoCountry, incoming?.country);
|
||||
final isContact = incoming?.isContact;
|
||||
if (isContact != null) add('В контактах', isContact ? 'да' : 'нет');
|
||||
add('IP собеседника', info?.peerIp);
|
||||
add('Сеть собеседника', info?.peerNetwork);
|
||||
add('Путь соединения', info?.path);
|
||||
add('Кодек', info?.audioCodec);
|
||||
add('Сервер', info?.region);
|
||||
add('Топология', info?.topology);
|
||||
if (isContact != null) {
|
||||
add(l10n.callInfoInContacts, isContact ? l10n.callValueYes : l10n.callValueNo);
|
||||
}
|
||||
add(l10n.callInfoPeerIp, info?.peerIp);
|
||||
add(l10n.callInfoPeerNetwork, info?.peerNetwork);
|
||||
add(l10n.callInfoPath, info?.path);
|
||||
add(l10n.callInfoCodec, info?.audioCodec);
|
||||
add(l10n.callInfoServer, info?.region);
|
||||
add(l10n.callInfoTopology, info?.topology);
|
||||
add('Conversation ID', info?.conversationId);
|
||||
if (info?.dtlsFingerprint != null) {
|
||||
add('DTLS', _shortFp(info!.dtlsFingerprint!));
|
||||
}
|
||||
if (session != null) {
|
||||
add('Статус', session!.mediaConnected ? 'соединён' : 'соединение…');
|
||||
add('Микрофон собеседника', session!.peerMuted ? 'выключен' : 'включён');
|
||||
add('Камера собеседника', session!.peerVideo ? 'включена' : 'выключена');
|
||||
add(
|
||||
l10n.callInfoStatus,
|
||||
session!.mediaConnected
|
||||
? l10n.callStatusValueConnected
|
||||
: l10n.callStatusValueConnecting,
|
||||
);
|
||||
add(
|
||||
l10n.callInfoPeerMic,
|
||||
session!.peerMuted ? l10n.callMicValueOff : l10n.callMicValueOn,
|
||||
);
|
||||
add(
|
||||
l10n.callInfoPeerCamera,
|
||||
session!.peerVideo ? l10n.callCameraValueOn : l10n.callCameraValueOff,
|
||||
);
|
||||
}
|
||||
|
||||
final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0;
|
||||
add('Видео-дорожка', vtracks > 0 ? 'есть ($vtracks)' : 'нет');
|
||||
add(
|
||||
l10n.callInfoVideoTrack,
|
||||
vtracks > 0
|
||||
? l10n.callInfoVideoTrackPresent(vtracks)
|
||||
: l10n.callValueNo,
|
||||
);
|
||||
final w = renderer.value.width.toInt();
|
||||
final h = renderer.value.height.toInt();
|
||||
add('Размер видео', (w > 0 && h > 0) ? '$w×$h' : '—');
|
||||
add('Отрисовка кадров', renderer.renderVideo ? 'да' : 'нет');
|
||||
add(l10n.callInfoVideoSize, (w > 0 && h > 0) ? '$w×$h' : '—');
|
||||
add(
|
||||
l10n.callInfoFrameRendering,
|
||||
renderer.renderVideo ? l10n.callValueYes : l10n.callValueNo,
|
||||
);
|
||||
|
||||
final badges = <Widget>[
|
||||
_badge(cs, Symbols.lock, 'Зашифрован'),
|
||||
_badge(cs, Symbols.call, 'Аудио'),
|
||||
if (info?.record == true) _badge(cs, Symbols.radio_button_checked, 'Запись'),
|
||||
_badge(cs, Symbols.lock, l10n.callBadgeEncrypted),
|
||||
_badge(cs, Symbols.call, l10n.callBadgeAudio),
|
||||
if (info?.record == true)
|
||||
_badge(cs, Symbols.radio_button_checked, l10n.callBadgeRecording),
|
||||
if (info?.denoise == true)
|
||||
_badge(cs, Symbols.noise_control_on, 'Шумоподавление'),
|
||||
if (info?.animoji == true) _badge(cs, Symbols.mood, 'Анимодзи'),
|
||||
_badge(cs, Symbols.noise_control_on, l10n.callBadgeNoiseSuppression),
|
||||
if (info?.animoji == true)
|
||||
_badge(cs, Symbols.mood, l10n.callBadgeAnimoji),
|
||||
];
|
||||
|
||||
return SafeArea(
|
||||
@@ -1375,7 +1417,7 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'О звонке',
|
||||
l10n.callInfoTitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
@@ -1393,7 +1435,7 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
if (rows.isEmpty)
|
||||
Text(
|
||||
'Данные появятся после соединения…',
|
||||
l10n.callInfoNoDataYet,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
for (final r in rows)
|
||||
|
||||
@@ -147,9 +147,7 @@ class _CallsTabState extends State<CallsTab> {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Open call details or initiate call
|
||||
},
|
||||
onTap: () {},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
@@ -268,17 +266,20 @@ class _CallsTabState extends State<CallsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteCall(CallLogEntry call) async {
|
||||
void _deleteCall(CallLogEntry call) {
|
||||
if (_removing.contains(call.id)) return;
|
||||
setState(() => _removing.add(call.id));
|
||||
final historyId = int.tryParse(call.id);
|
||||
if (historyId != null) {
|
||||
unawaited(CallsModule(api).deleteHistory([historyId]));
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 260));
|
||||
}
|
||||
|
||||
void _onRemovalComplete(String id) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_calls.removeWhere((c) => c.id == call.id);
|
||||
_removing.remove(call.id);
|
||||
_calls.removeWhere((c) => c.id == id);
|
||||
_removing.remove(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,8 +296,11 @@ class _CallsTabState extends State<CallsTab> {
|
||||
if (active != null) {
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
CallScreen(name: call.name, avatarUrl: avatarUrl, session: active),
|
||||
builder: (_) => CallScreen(
|
||||
name: call.name,
|
||||
avatarUrl: avatarUrl,
|
||||
session: active,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -437,6 +441,7 @@ class _CallsTabState extends State<CallsTab> {
|
||||
return _RemovableCallEntry(
|
||||
key: ValueKey(call.id),
|
||||
removing: _removing.contains(call.id),
|
||||
onDismissed: () => _onRemovalComplete(call.id),
|
||||
child: _buildCallItem(context, cs, call),
|
||||
);
|
||||
},
|
||||
@@ -451,11 +456,13 @@ class _CallsTabState extends State<CallsTab> {
|
||||
|
||||
class _RemovableCallEntry extends StatefulWidget {
|
||||
final bool removing;
|
||||
final VoidCallback onDismissed;
|
||||
final Widget child;
|
||||
|
||||
const _RemovableCallEntry({
|
||||
required Key key,
|
||||
required this.removing,
|
||||
required this.onDismissed,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -475,6 +482,16 @@ class _RemovableCallEntryState extends State<_RemovableCallEntry>
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addStatusListener(_onStatus);
|
||||
}
|
||||
|
||||
void _onStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.dismissed) widget.onDismissed();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _RemovableCallEntry oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -483,6 +500,7 @@ class _RemovableCallEntryState extends State<_RemovableCallEntry>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeStatusListener(_onStatus);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user