небольшие изменения

This commit is contained in:
Jganenokk
2026-07-05 19:07:27 +07:00
parent 26f0094705
commit 9f9b0e0734
214 changed files with 27592 additions and 15353 deletions
+4405
View File
File diff suppressed because it is too large Load Diff
+83 -33
View File
@@ -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'];
+24 -38
View File
@@ -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']),
);
}
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',
);
}
}
+74 -77
View File
@@ -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'];
+293
View File
@@ -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;
}
+105
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+39 -41
View File
@@ -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;
+5 -4
View File
@@ -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;
+55 -48
View File
@@ -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 {
+253 -200
View File
@@ -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;
+40 -54
View File
@@ -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
View File
@@ -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;
}
+54 -9
View File
@@ -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();
}
}
+5 -3
View File
@@ -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;
}
}
+11
View File
@@ -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;
+48 -42
View File
@@ -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;
}
+19 -13
View File
@@ -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
View File
@@ -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) =>
+24 -8
View File
@@ -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');
}
}
}
+25 -22
View File
@@ -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();
+69 -83
View File
@@ -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;
+15 -11
View File
@@ -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);
}
+14 -17
View File
@@ -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) {
+14 -17
View File
@@ -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) {
+15 -14
View File
@@ -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;
+16 -32
View File
@@ -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);
}
+11
View File
@@ -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);
+14 -11
View File
@@ -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);
}
+15 -11
View File
@@ -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);
}
+1 -5
View File
@@ -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);
+14 -11
View File
@@ -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);
}
+14 -11
View File
@@ -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);
}
+14 -16
View File
@@ -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) {
+15 -11
View File
@@ -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);
}
+14 -11
View File
@@ -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);
}
+14 -11
View File
@@ -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);
}
+14 -11
View File
@@ -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);
}
+14 -11
View File
@@ -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);
}
+14 -24
View File
@@ -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) {
+6 -3
View File
@@ -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) {
+18 -17
View File
@@ -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);
}
+24 -12
View File
@@ -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));
+39 -28
View File
@@ -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();
}
}
+72
View File
@@ -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));
}
}
+26 -6
View File
@@ -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);
}
}
+17 -4
View File
@@ -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);
+4 -2
View File
@@ -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()) {
+9 -5
View File
@@ -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
+32 -11
View File
@@ -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;
+34
View File
@@ -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;
}
+9 -5
View File
@@ -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);
}
+20 -3
View File
@@ -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
View File
@@ -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,
+141 -23
View File
@@ -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],
+31 -67
View File
@@ -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');
}
}
}
+4 -18
View File
@@ -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)}';
}
}
+12 -41
View File
@@ -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, '');
+70
View File
@@ -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));
}
}
+24 -23
View File
@@ -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)}';
}
}
+29 -17
View File
@@ -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() {
+13 -24
View File
@@ -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));
+10 -15
View File
@@ -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>[];
+13 -8
View File
@@ -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;
+20
View File
@@ -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();
}
+6 -38
View File
@@ -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
View File
@@ -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 'Мужской';
+20
View File
@@ -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)}';
}
+21 -9
View File
@@ -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();
}
+24 -3
View File
@@ -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;
+6
View File
@@ -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;
}
+9
View File
@@ -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>[];
+8 -18
View File
@@ -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));
+149
View File
@@ -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,
),
),
],
),
),
),
),
),
],
);
}
}
+70
View File
@@ -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,
),
),
],
);
}
}
+38
View File
@@ -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',
),
),
),
],
);
}
}
+431
View File
@@ -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,
),
),
],
),
);
}
}
+140
View File
@@ -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,
),
],
),
),
),
),
),
],
);
}
}
+210
View File
@@ -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,
),
],
),
),
),
),
),
],
);
}
}
+180
View File
@@ -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,
+6 -2
View File
@@ -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),
+159 -117
View File
@@ -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)
+27 -9
View File
@@ -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