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

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
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;
}