небольшие изменения
This commit is contained in:
+168
-909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import '../../api.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
|
||||
abstract class AccountApiBase {
|
||||
final Api api;
|
||||
const AccountApiBase(this.api);
|
||||
|
||||
void ensureOnline() {
|
||||
if (api.state != SessionState.online) {
|
||||
throw StateError(
|
||||
'AccountModule: сессия не онлайн (текущее состояние: ${api.state.name})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void checkPacketError(Packet packet, String method) {
|
||||
throwIfPacketError(packet);
|
||||
}
|
||||
|
||||
Map requireMapPayload(Packet packet, String method) {
|
||||
checkPacketError(packet, method);
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('$method: неожиданный тип payload: ${data.runtimeType}');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../core/storage/app_database.dart';
|
||||
|
||||
class PrivacyConfig {
|
||||
final String searchByPhone;
|
||||
final String incomingCall;
|
||||
final bool doubleTapReactionDisabled;
|
||||
final bool safeModeNoPin;
|
||||
final String? doubleTapReactionValue;
|
||||
final String familyProtection;
|
||||
final bool pushDetails;
|
||||
final bool hidden;
|
||||
final String chatsInvite;
|
||||
final bool pushNewContacts;
|
||||
final bool unsafeFiles;
|
||||
final String phoneNumberPrivacy;
|
||||
final String inactiveTtl;
|
||||
final bool showReadMark;
|
||||
final bool altKeyboard;
|
||||
final bool contentLevelAccess;
|
||||
final String stickersSuggest;
|
||||
final bool safeMode;
|
||||
final bool audioTranscriptionEnabled;
|
||||
final String chatsPushNotification;
|
||||
final String mCallPushNotification;
|
||||
final String pushSound;
|
||||
final String chatsPushSound;
|
||||
final String hash;
|
||||
|
||||
const PrivacyConfig({
|
||||
required this.searchByPhone,
|
||||
required this.incomingCall,
|
||||
required this.doubleTapReactionDisabled,
|
||||
required this.safeModeNoPin,
|
||||
this.doubleTapReactionValue,
|
||||
required this.familyProtection,
|
||||
required this.pushDetails,
|
||||
required this.hidden,
|
||||
required this.chatsInvite,
|
||||
required this.pushNewContacts,
|
||||
required this.unsafeFiles,
|
||||
required this.phoneNumberPrivacy,
|
||||
required this.inactiveTtl,
|
||||
required this.showReadMark,
|
||||
required this.altKeyboard,
|
||||
required this.contentLevelAccess,
|
||||
required this.stickersSuggest,
|
||||
required this.safeMode,
|
||||
required this.audioTranscriptionEnabled,
|
||||
required this.chatsPushNotification,
|
||||
required this.mCallPushNotification,
|
||||
required this.pushSound,
|
||||
required this.chatsPushSound,
|
||||
required this.hash,
|
||||
});
|
||||
|
||||
factory PrivacyConfig.fromMap(Map<dynamic, dynamic> map) {
|
||||
return PrivacyConfig(
|
||||
searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL',
|
||||
incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS',
|
||||
doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false,
|
||||
safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false,
|
||||
doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(),
|
||||
familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF',
|
||||
pushDetails: map['PUSH_DETAILS'] ?? false,
|
||||
hidden: map['HIDDEN'] ?? true,
|
||||
chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS',
|
||||
pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false,
|
||||
unsafeFiles: map['UNSAFE_FILES'] ?? true,
|
||||
phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL',
|
||||
inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M',
|
||||
showReadMark: map['SHOW_READ_MARK'] ?? true,
|
||||
altKeyboard: map['ALT_KEYBOARD'] ?? false,
|
||||
contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false,
|
||||
stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON',
|
||||
safeMode: map['SAFE_MODE'] ?? false,
|
||||
audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true,
|
||||
chatsPushNotification: map['CHATS_PUSH_NOTIFICATION']?.toString() ?? 'ON',
|
||||
mCallPushNotification:
|
||||
map['M_CALL_PUSH_NOTIFICATION']?.toString() ?? 'ON',
|
||||
pushSound: map['PUSH_SOUND']?.toString() ?? 'oki.aiff',
|
||||
chatsPushSound: map['CHATS_PUSH_SOUND']?.toString() ?? 'oki.aiff',
|
||||
hash: map['hash']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => jsonEncode({
|
||||
'SEARCH_BY_PHONE': searchByPhone,
|
||||
'INCOMING_CALL': incomingCall,
|
||||
'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled,
|
||||
'SAFE_MODE_NO_PIN': safeModeNoPin,
|
||||
'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue,
|
||||
'FAMILY_PROTECTION': familyProtection,
|
||||
'PUSH_DETAILS': pushDetails,
|
||||
'HIDDEN': hidden,
|
||||
'CHATS_INVITE': chatsInvite,
|
||||
'PUSH_NEW_CONTACTS': pushNewContacts,
|
||||
'UNSAFE_FILES': unsafeFiles,
|
||||
'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy,
|
||||
'INACTIVE_TTL': inactiveTtl,
|
||||
'SHOW_READ_MARK': showReadMark,
|
||||
'ALT_KEYBOARD': altKeyboard,
|
||||
'CONTENT_LEVEL_ACCESS': contentLevelAccess,
|
||||
'STICKERS_SUGGEST': stickersSuggest,
|
||||
'SAFE_MODE': safeMode,
|
||||
'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled,
|
||||
'CHATS_PUSH_NOTIFICATION': chatsPushNotification,
|
||||
'M_CALL_PUSH_NOTIFICATION': mCallPushNotification,
|
||||
'PUSH_SOUND': pushSound,
|
||||
'CHATS_PUSH_SOUND': chatsPushSound,
|
||||
'hash': hash,
|
||||
});
|
||||
|
||||
factory PrivacyConfig.fromJson(String json) {
|
||||
try {
|
||||
final map = jsonDecode(json) as Map<String, dynamic>;
|
||||
return PrivacyConfig.fromMap(map);
|
||||
} catch (_) {
|
||||
return PrivacyConfig.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static PrivacyConfig empty() {
|
||||
return const PrivacyConfig(
|
||||
searchByPhone: 'ALL',
|
||||
incomingCall: 'CONTACTS',
|
||||
doubleTapReactionDisabled: false,
|
||||
safeModeNoPin: false,
|
||||
familyProtection: 'OFF',
|
||||
pushDetails: false,
|
||||
hidden: true,
|
||||
chatsInvite: 'CONTACTS',
|
||||
pushNewContacts: false,
|
||||
unsafeFiles: true,
|
||||
phoneNumberPrivacy: 'ALL',
|
||||
inactiveTtl: '6M',
|
||||
showReadMark: true,
|
||||
altKeyboard: false,
|
||||
contentLevelAccess: false,
|
||||
stickersSuggest: 'ON',
|
||||
safeMode: false,
|
||||
audioTranscriptionEnabled: true,
|
||||
chatsPushNotification: 'ON',
|
||||
mCallPushNotification: 'ON',
|
||||
pushSound: 'oki.aiff',
|
||||
chatsPushSound: 'oki.aiff',
|
||||
hash: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BlockedContact {
|
||||
final int id;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? baseUrl;
|
||||
final int? photoId;
|
||||
final String status;
|
||||
final int registrationTime;
|
||||
final int updateTime;
|
||||
|
||||
const BlockedContact({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.baseUrl,
|
||||
this.photoId,
|
||||
required this.status,
|
||||
required this.registrationTime,
|
||||
required this.updateTime,
|
||||
});
|
||||
|
||||
factory BlockedContact.fromMap(Map<dynamic, dynamic> map) {
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
final names = map['names'] as List?;
|
||||
if (names != null && names.isNotEmpty) {
|
||||
for (final n in names) {
|
||||
if (n is Map) {
|
||||
firstName = n['firstName'] as String?;
|
||||
lastName = n['lastName'] as String?;
|
||||
if (n['type'] == 'ONEME') break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BlockedContact(
|
||||
id: map['id'] as int? ?? 0,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
photoId: map['photoId'] as int?,
|
||||
status: map['status']?.toString() ?? 'BLOCKED',
|
||||
registrationTime: map['registrationTime'] as int? ?? 0,
|
||||
updateTime: map['updateTime'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TwoFactorDetails {
|
||||
final bool enabled;
|
||||
final String? email;
|
||||
final String? hint;
|
||||
|
||||
const TwoFactorDetails({required this.enabled, this.email, this.hint});
|
||||
}
|
||||
|
||||
enum AuthRequestType {
|
||||
startAuth('START_AUTH'),
|
||||
resend('RESEND'),
|
||||
checkCode('CHECK_CODE'),
|
||||
register('REGISTER');
|
||||
|
||||
const AuthRequestType(this.value);
|
||||
final String value;
|
||||
}
|
||||
|
||||
enum LoginStatus { idle, loading, success, error }
|
||||
|
||||
class WrongDeviceTokenException implements Exception {
|
||||
const WrongDeviceTokenException();
|
||||
@override
|
||||
String toString() => 'WrongDeviceTokenException';
|
||||
}
|
||||
|
||||
class RequestCodeResult {
|
||||
final String token;
|
||||
|
||||
const RequestCodeResult({required this.token});
|
||||
}
|
||||
|
||||
class PresetAvatar {
|
||||
final int id;
|
||||
final String url;
|
||||
|
||||
const PresetAvatar({required this.id, required this.url});
|
||||
}
|
||||
|
||||
class PresetAvatarCategory {
|
||||
final String name;
|
||||
final List<PresetAvatar> avatars;
|
||||
|
||||
const PresetAvatarCategory({required this.name, required this.avatars});
|
||||
}
|
||||
|
||||
class VerifyCodeResult {
|
||||
final Map<dynamic, dynamic> payload;
|
||||
|
||||
const VerifyCodeResult({required this.payload});
|
||||
|
||||
String? get loginToken => _nestedToken('LOGIN');
|
||||
|
||||
String? get registerToken => _nestedToken('REGISTER');
|
||||
|
||||
bool get isRegistration => registerToken != null && loginToken == null;
|
||||
|
||||
List<PresetAvatarCategory> get presetAvatars {
|
||||
final raw = payload['presetAvatars'];
|
||||
if (raw is! List) return const [];
|
||||
final categories = <PresetAvatarCategory>[];
|
||||
for (final cat in raw) {
|
||||
if (cat is! Map) continue;
|
||||
final avatarsRaw = cat['avatars'];
|
||||
if (avatarsRaw is! List) continue;
|
||||
final avatars = <PresetAvatar>[];
|
||||
for (final a in avatarsRaw) {
|
||||
if (a is! Map) continue;
|
||||
final id = a['id'];
|
||||
final url = a['url'];
|
||||
if (id is int && url is String && url.isNotEmpty) {
|
||||
avatars.add(PresetAvatar(id: id, url: url));
|
||||
}
|
||||
}
|
||||
if (avatars.isNotEmpty) {
|
||||
categories.add(
|
||||
PresetAvatarCategory(
|
||||
name: cat['name']?.toString() ?? '',
|
||||
avatars: avatars,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
bool get requiresPassword => payload['passwordChallenge'] != null;
|
||||
|
||||
Map<dynamic, dynamic>? get passwordChallenge {
|
||||
final c = payload['passwordChallenge'];
|
||||
return c is Map ? c.cast<dynamic, dynamic>() : null;
|
||||
}
|
||||
|
||||
String? get challengeTrackId => passwordChallenge?['trackId'] as String?;
|
||||
|
||||
String? get challengeHint => passwordChallenge?['hint'] as String?;
|
||||
|
||||
int? get accountId {
|
||||
final profileData = payload['profile'];
|
||||
if (profileData is! Map) return null;
|
||||
final contact = profileData['contact'];
|
||||
if (contact is! Map) return null;
|
||||
return contact['id'] as int?;
|
||||
}
|
||||
|
||||
String? _nestedToken(String key) {
|
||||
final attrs = payload['tokenAttrs'];
|
||||
if (attrs is! Map) return null;
|
||||
final entry = attrs[key];
|
||||
if (entry is! Map) return null;
|
||||
return entry['token'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
class TwoFactorResult {
|
||||
final String loginToken;
|
||||
|
||||
const TwoFactorResult({required this.loginToken});
|
||||
}
|
||||
|
||||
class LoginSyncParams {
|
||||
final int chatsSync;
|
||||
final int contactsSync;
|
||||
final int callsSync;
|
||||
final int draftsSync;
|
||||
final int bannersSync;
|
||||
final int presenceSync;
|
||||
final int lastLogin;
|
||||
final String? configHash;
|
||||
final String? chatCacheFingerprint;
|
||||
|
||||
const LoginSyncParams({
|
||||
required this.chatsSync,
|
||||
required this.contactsSync,
|
||||
required this.callsSync,
|
||||
required this.draftsSync,
|
||||
required this.bannersSync,
|
||||
required this.presenceSync,
|
||||
required this.lastLogin,
|
||||
this.configHash,
|
||||
this.chatCacheFingerprint,
|
||||
});
|
||||
|
||||
static Future<LoginSyncParams?> fromDatabase(int accountId) async {
|
||||
final values = await AppDatabase.getAllSyncValues(accountId);
|
||||
final lastLogin = values[SyncKey.lastLogin];
|
||||
if (lastLogin == null) return null;
|
||||
|
||||
return LoginSyncParams(
|
||||
chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0,
|
||||
contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0,
|
||||
callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0,
|
||||
draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0,
|
||||
bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0,
|
||||
presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1,
|
||||
lastLogin: int.tryParse(lastLogin) ?? 0,
|
||||
configHash: values[SyncKey.configHash],
|
||||
chatCacheFingerprint: values[SyncKey.chatCacheFingerprint],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionInfo {
|
||||
final int? id;
|
||||
final String client;
|
||||
final String location;
|
||||
final bool current;
|
||||
final int time;
|
||||
final String info;
|
||||
|
||||
const SessionInfo({
|
||||
this.id,
|
||||
required this.client,
|
||||
required this.location,
|
||||
required this.current,
|
||||
required this.time,
|
||||
required this.info,
|
||||
});
|
||||
|
||||
factory SessionInfo.fromMap(Map<dynamic, dynamic> map) {
|
||||
return SessionInfo(
|
||||
id: map['id'] is int
|
||||
? map['id']
|
||||
: (int.tryParse(map['id']?.toString() ?? '')),
|
||||
client: map['client'] ?? '',
|
||||
location: map['location'] ?? '',
|
||||
current: map['current'] ?? false,
|
||||
time: map['time'] ?? 0,
|
||||
info: map['info'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
int get uniqueId => Object.hash(id, client, time, info);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SessionInfo &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
client == other.client &&
|
||||
location == other.location &&
|
||||
current == other.current &&
|
||||
time == other.time &&
|
||||
info == other.info;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, client, location, current, time, info);
|
||||
}
|
||||
|
||||
class LoginResult {
|
||||
final ProfileData profile;
|
||||
final String? updatedToken;
|
||||
final int serverTime;
|
||||
final Map<dynamic, dynamic> raw;
|
||||
|
||||
const LoginResult({
|
||||
required this.profile,
|
||||
required this.updatedToken,
|
||||
required this.serverTime,
|
||||
required this.raw,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import '../../api.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import 'account_base.dart';
|
||||
import 'account_models.dart';
|
||||
|
||||
class PrivacyModule extends AccountApiBase {
|
||||
PrivacyModule(super.api);
|
||||
|
||||
static const String _defaultPushSound = 'oki.aiff';
|
||||
|
||||
Future<PrivacyConfig> getPrivacyConfig() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
final saved = await AppDatabase.getPrivacyConfig(accountId);
|
||||
if (saved != null) return PrivacyConfig.fromJson(saved);
|
||||
}
|
||||
return PrivacyConfig.empty();
|
||||
}
|
||||
|
||||
Future<List<BlockedContact>> getBlockedContacts() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.contactList, {
|
||||
'status': 'BLOCKED',
|
||||
'count': 100,
|
||||
'from': 0,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'getBlockedContacts');
|
||||
final contacts = data['contacts'] as List?;
|
||||
if (contacts == null) return [];
|
||||
return contacts
|
||||
.whereType<Map>()
|
||||
.map((c) => BlockedContact.fromMap(c.cast<dynamic, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<PrivacyConfig> updatePrivacyConfig(
|
||||
Map<String, dynamic> settings,
|
||||
) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'settings': {'user': settings},
|
||||
};
|
||||
final packet = await api.sendRequest(Opcode.config, payload);
|
||||
final data = requireMapPayload(packet, 'updatePrivacyConfig');
|
||||
final user = data['user'];
|
||||
if (user is! Map) {
|
||||
throw Exception('updatePrivacyConfig: отсутствует user в payload');
|
||||
}
|
||||
final config = PrivacyConfig.fromMap(user.cast<dynamic, dynamic>());
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.savePrivacyConfig(accountId, config.toJson());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
Future<PrivacyConfig> setChatsPushNotification(bool value) =>
|
||||
updatePrivacyConfig({'CHATS_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'});
|
||||
|
||||
Future<PrivacyConfig> setMessagePreview(bool value) =>
|
||||
updatePrivacyConfig({'PUSH_DETAILS': value});
|
||||
|
||||
Future<PrivacyConfig> setNotificationSound(bool value) =>
|
||||
updatePrivacyConfig({
|
||||
'PUSH_SOUND': value ? _defaultPushSound : '',
|
||||
'CHATS_PUSH_SOUND': value ? _defaultPushSound : '',
|
||||
});
|
||||
|
||||
Future<PrivacyConfig> setCallNotifications(bool value) =>
|
||||
updatePrivacyConfig({'M_CALL_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'});
|
||||
|
||||
Future<PrivacyConfig> setNewContacts(bool value) =>
|
||||
updatePrivacyConfig({'PUSH_NEW_CONTACTS': value});
|
||||
|
||||
Future<void> registerPushToken(String pushToken) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.config, <dynamic, dynamic>{
|
||||
'pushToken': pushToken,
|
||||
'pushOptions': 0,
|
||||
});
|
||||
if (packet.isError) {
|
||||
final msg = messageFromErrorPayload(packet.payload).toUpperCase();
|
||||
if (msg.contains('WRONG_DEVICE_TOKEN') ||
|
||||
msg.contains('WRONG.DEVICE.TOKEN')) {
|
||||
throw const WrongDeviceTokenException();
|
||||
}
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unregisterPushToken(String pushToken) async {
|
||||
if (api.state != SessionState.online) return;
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
final authToken = await TokenStorage.readToken(accountId);
|
||||
if (authToken == null) return;
|
||||
await api.sendRequest(Opcode.logout, <dynamic, dynamic>{
|
||||
'token': authToken,
|
||||
'pushToken': pushToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import 'account_base.dart';
|
||||
|
||||
class ProfileModule extends AccountApiBase {
|
||||
ProfileModule(super.api);
|
||||
|
||||
Future<ProfileData> _applyProfileResponse(Packet packet) async {
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final profile = data['profile'] as Map?;
|
||||
if (profile == null) throw Exception('No profile in response');
|
||||
final contact = profile['contact'] as Map?;
|
||||
if (contact == null) throw Exception('No contact in response');
|
||||
final newProfile = ProfileData.fromServerMap(
|
||||
contact.cast<dynamic, dynamic>(),
|
||||
);
|
||||
await AppDatabase.saveProfile(newProfile, isActive: true);
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileName(
|
||||
String firstName,
|
||||
String? lastName,
|
||||
) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{'firstName': firstName};
|
||||
if (lastName != null) payload['lastName'] = lastName;
|
||||
final packet = await api.sendRequest(Opcode.profile, payload);
|
||||
return _applyProfileResponse(packet);
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileAvatar(
|
||||
String photoToken, {
|
||||
String avatarType = 'USER_AVATAR',
|
||||
}) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.profile, {
|
||||
'photoToken': photoToken,
|
||||
'avatarType': avatarType,
|
||||
});
|
||||
return _applyProfileResponse(packet);
|
||||
}
|
||||
|
||||
Future<String> getAvatarUploadUrl() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.photoUpload, {
|
||||
'count': 1,
|
||||
'profile': true,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final url = data['url'] as String?;
|
||||
if (url == null) throw Exception('No url in response');
|
||||
return url;
|
||||
}
|
||||
|
||||
Future<ProfileData> removeProfilePhoto(int photoId) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.removeContactPhoto, {
|
||||
'photoId': photoId,
|
||||
});
|
||||
return _applyProfileResponse(packet);
|
||||
}
|
||||
|
||||
Future<ProfileData> processProfileUpdate(
|
||||
Future<Packet> requestFuture,
|
||||
String tag,
|
||||
) async {
|
||||
final completer = Completer<ProfileData>();
|
||||
final sub = api.pushStream
|
||||
.where((p) => p.opcode == Opcode.notifProfile)
|
||||
.listen((push) {
|
||||
if (completer.isCompleted) return;
|
||||
final payload = push.payload;
|
||||
if (payload is! Map) return;
|
||||
final profile = payload['profile'];
|
||||
if (profile is! Map) return;
|
||||
final contact = profile['contact'];
|
||||
if (contact is! Map) return;
|
||||
completer.complete(
|
||||
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()),
|
||||
);
|
||||
});
|
||||
final timer = Timer(const Duration(seconds: 15), () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
Exception('Таймаут ожидания обновления профиля'),
|
||||
);
|
||||
}
|
||||
});
|
||||
try {
|
||||
final packet = await requestFuture;
|
||||
checkPacketError(packet, tag);
|
||||
return await completer.future;
|
||||
} finally {
|
||||
timer.cancel();
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import 'account_base.dart';
|
||||
import 'account_models.dart';
|
||||
|
||||
class SessionsModule extends AccountApiBase {
|
||||
SessionsModule(super.api);
|
||||
|
||||
Future<List<SessionInfo>> getSessions() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.sessionsInfo, {});
|
||||
checkPacketError(packet, 'getSessions');
|
||||
final data = packet.payload;
|
||||
if (data is! Map || data['sessions'] is! List) return [];
|
||||
final sessions = data['sessions'] as List;
|
||||
return sessions
|
||||
.map((s) => SessionInfo.fromMap(s as Map<dynamic, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> terminateOtherSessions() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.sessionsClose, {});
|
||||
checkPacketError(packet, 'terminateOtherSessions');
|
||||
}
|
||||
|
||||
Future<void> authorizeWebQrLogin(String qrLink) async {
|
||||
ensureOnline();
|
||||
final link = qrLink.trim();
|
||||
if (link.isEmpty) {
|
||||
throw ArgumentError('Пустая ссылка из QR');
|
||||
}
|
||||
|
||||
await api.sendRequest(Opcode.ping, {'interactive': true});
|
||||
await api.sendRequest(Opcode.sessionsInfo, {});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
final packet = await api.sendRequest(Opcode.authQrApprove, {
|
||||
'qrLink': link,
|
||||
});
|
||||
checkPacketError(packet, 'authorizeWebQrLogin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import 'account_base.dart';
|
||||
import 'account_models.dart';
|
||||
import 'profile_module.dart';
|
||||
|
||||
class TwoFactorModule extends AccountApiBase {
|
||||
final ProfileModule _profile;
|
||||
TwoFactorModule(super.api, this._profile);
|
||||
|
||||
Future<String> create2faTrack() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0});
|
||||
final data = requireMapPayload(packet, 'create2faTrack');
|
||||
final trackId = data['trackId'] as String?;
|
||||
if (trackId == null) {
|
||||
throw Exception('create2faTrack: отсутствует trackId');
|
||||
}
|
||||
return trackId;
|
||||
}
|
||||
|
||||
Future<void> set2faPassword(String trackId, String password) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authValidatePassword, {
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
});
|
||||
checkPacketError(packet, 'set2faPassword');
|
||||
if (packet.payload != null && packet.payload is! Map) {
|
||||
throw Exception('set2faPassword: неожиданный ответ');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set2faHint(String trackId, String hint) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authValidateHint, {
|
||||
'trackId': trackId,
|
||||
'hint': hint,
|
||||
});
|
||||
checkPacketError(packet, 'set2faHint');
|
||||
if (packet.payload != null && packet.payload is! Map) {
|
||||
throw Exception('set2faHint: неожиданный ответ');
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> verify2faEmail(String trackId, String email) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authVerifyEmail, {
|
||||
'trackId': trackId,
|
||||
'email': email,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'verify2faEmail');
|
||||
final blockingDuration = data['blockingDuration'] as int? ?? 60;
|
||||
return blockingDuration;
|
||||
}
|
||||
|
||||
Future<String> verify2faCode(String trackId, String code) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCheckEmail, {
|
||||
'trackId': trackId,
|
||||
'verifyCode': code,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'verify2faCode');
|
||||
final email = data['email'] as String? ?? '';
|
||||
return email;
|
||||
}
|
||||
|
||||
Future<ProfileData> confirm2fa({
|
||||
required String trackId,
|
||||
required String password,
|
||||
String? hint,
|
||||
bool withEmail = true,
|
||||
}) async {
|
||||
ensureOnline();
|
||||
final capabilities = <int>[0, if (hint != null) 3, if (withEmail) 4];
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': capabilities,
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'confirm2fa',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> enter2faPanel() async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0});
|
||||
final data = requireMapPayload(packet, 'enter2faPanel');
|
||||
final trackId = data['trackId'] as String?;
|
||||
if (trackId == null) {
|
||||
throw Exception('enter2faPanel: отсутствует trackId');
|
||||
}
|
||||
return trackId;
|
||||
}
|
||||
|
||||
Future<TwoFactorDetails> get2faDetails(String trackId) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.auth2faDetails, {
|
||||
'trackId': trackId,
|
||||
});
|
||||
final data = requireMapPayload(packet, 'get2faDetails');
|
||||
final password = data['password'] as Map?;
|
||||
return TwoFactorDetails(
|
||||
enabled: password?['enabled'] ?? false,
|
||||
email: password?['email'] as String?,
|
||||
hint: password?['hint'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TwoFactorDetails> get2faStatus() async {
|
||||
final trackId = await enter2faPanel();
|
||||
return get2faDetails(trackId);
|
||||
}
|
||||
|
||||
Future<void> check2faPassword(String trackId, String password) async {
|
||||
ensureOnline();
|
||||
final packet = await api.sendRequest(Opcode.authCheckPassword, {
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
});
|
||||
checkPacketError(packet, 'check2faPassword');
|
||||
final data = packet.payload;
|
||||
if (data is Map && data['error'] != null) {
|
||||
throw Exception('Неверный пароль');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProfileData> update2faPassword({
|
||||
required String trackId,
|
||||
required String newPassword,
|
||||
String? hint,
|
||||
}) async {
|
||||
ensureOnline();
|
||||
final validatePacket = await api.sendRequest(Opcode.authValidatePassword, {
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
});
|
||||
checkPacketError(validatePacket, 'update2faPassword: validate');
|
||||
if (validatePacket.payload != null && validatePacket.payload is! Map) {
|
||||
throw Exception('update2faPassword: неожиданный ответ при валидации');
|
||||
}
|
||||
|
||||
if (hint != null) {
|
||||
final hintPacket = await api.sendRequest(Opcode.authValidateHint, {
|
||||
'trackId': trackId,
|
||||
'hint': hint,
|
||||
});
|
||||
checkPacketError(hintPacket, 'update2faPassword: hint');
|
||||
}
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': <int>[1, if (hint != null) 3],
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'update2faPassword',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> commit2faEmailChange(String trackId) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [4],
|
||||
'trackId': trackId,
|
||||
};
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'commit2faEmailChange',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> remove2fa(String trackId) async {
|
||||
ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [5],
|
||||
'trackId': trackId,
|
||||
'remove2fa': true,
|
||||
};
|
||||
return _profile.processProfileUpdate(
|
||||
api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'remove2fa',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
// Backend module for parsing calls from Komet platform
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'contacts.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/utils/ids.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
|
||||
enum CallStatus { missed, canceled, outgoing, incoming }
|
||||
|
||||
/// Параметры подключения для исходящего звонка (ответ opcode 78).
|
||||
class OutgoingCallParams {
|
||||
final String conversationId;
|
||||
|
||||
/// Полный ws2 URL с уже вшитым токеном (`internalCallerParams.endpoint`).
|
||||
final String endpoint;
|
||||
|
||||
/// Наш id в системе звонков (`internalCallerParams.id.internal`).
|
||||
final int callsUserId;
|
||||
|
||||
final int peerExternalId;
|
||||
@@ -68,70 +65,92 @@ class CallLogEntry {
|
||||
});
|
||||
}
|
||||
|
||||
class _CallerEndpointMissingException implements Exception {
|
||||
final String message;
|
||||
|
||||
const _CallerEndpointMissingException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'Exception: $message';
|
||||
}
|
||||
|
||||
typedef _CallerEndpoint = ({String endpoint, int callsUserId, int? external});
|
||||
|
||||
class CallsModule {
|
||||
final Api _api;
|
||||
|
||||
CallsModule(this._api);
|
||||
|
||||
/// Инициирует исходящий 1:1 звонок (opcode 78).
|
||||
_CallerEndpoint _parseCallerEndpoint(
|
||||
Map payload,
|
||||
String key, {
|
||||
required String context,
|
||||
}) {
|
||||
final raw = payload[key];
|
||||
final parsed = raw is String
|
||||
? jsonDecode(raw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = parsed['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw _CallerEndpointMissingException('$context: no endpoint');
|
||||
}
|
||||
|
||||
final id = parsed['id'];
|
||||
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
|
||||
final external = id is Map ? int.tryParse('${id['external']}') : null;
|
||||
|
||||
return (endpoint: endpoint, callsUserId: callsUserId, external: external);
|
||||
}
|
||||
|
||||
Future<OutgoingCallParams> initiateCall(
|
||||
int calleeId, {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
final conversationId = _uuidV4();
|
||||
final conversationId = uuidV4();
|
||||
|
||||
final response = await _api.sendRequest(Opcode.videoChatStartActive, {
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, {
|
||||
'conversationId': conversationId,
|
||||
'calleeIds': [calleeId],
|
||||
'internalParams': _internalParams(),
|
||||
'isVideo': isVideo,
|
||||
});
|
||||
|
||||
if (!response.isOk || response.payload is! Map) {
|
||||
if (payload == null) {
|
||||
throw Exception('initiateCall: bad response');
|
||||
}
|
||||
final payload = response.payload as Map;
|
||||
|
||||
final icpRaw = payload['internalCallerParams'];
|
||||
final icp = icpRaw is String
|
||||
? jsonDecode(icpRaw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = icp['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw Exception('initiateCall: no endpoint');
|
||||
}
|
||||
|
||||
final id = icp['id'];
|
||||
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
|
||||
final external =
|
||||
(id is Map ? int.tryParse('${id['external']}') : null) ?? calleeId;
|
||||
final parsed = _parseCallerEndpoint(
|
||||
payload,
|
||||
'internalCallerParams',
|
||||
context: 'initiateCall',
|
||||
);
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? conversationId,
|
||||
endpoint: endpoint,
|
||||
callsUserId: callsUserId,
|
||||
peerExternalId: external,
|
||||
endpoint: parsed.endpoint,
|
||||
callsUserId: parsed.callsUserId,
|
||||
peerExternalId: parsed.external ?? calleeId,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
|
||||
String _internalParams() => jsonEncode({
|
||||
'platform': 'ANDROID',
|
||||
'sdkVersion': '0.1.16.4',
|
||||
'clientAppKey': 'CGPGAGLGDIHBABABA',
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'protocolVersion': 5,
|
||||
'onlyAdminCanRecord': false,
|
||||
'waitForAdmin': false,
|
||||
'capabilities': '3c03f',
|
||||
});
|
||||
'platform': 'ANDROID',
|
||||
'sdkVersion': '0.1.16.4',
|
||||
'clientAppKey': 'CGPGAGLGDIHBABABA',
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'protocolVersion': 5,
|
||||
'onlyAdminCanRecord': false,
|
||||
'waitForAdmin': false,
|
||||
'capabilities': '3c03f',
|
||||
});
|
||||
|
||||
Future<CallLinkPreview?> resolveCallLink(String url) async {
|
||||
final response = await _api.sendRequest(Opcode.linkInfo, {'link': url});
|
||||
if (!response.isOk || response.payload is! Map) return null;
|
||||
final payload = await _api.sendRequestMap(Opcode.linkInfo, {'link': url});
|
||||
if (payload == null) return null;
|
||||
|
||||
final vc = (response.payload as Map)['videoConference'];
|
||||
final vc = payload['videoConference'];
|
||||
if (vc is! Map) return null;
|
||||
|
||||
return CallLinkPreview(
|
||||
@@ -146,59 +165,38 @@ class CallsModule {
|
||||
String token, {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
final response = await _api.sendRequest(Opcode.videoChatJoinByLink, {
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, {
|
||||
'joinLink': token,
|
||||
'internalParams': _internalParams(),
|
||||
'isVideo': isVideo,
|
||||
});
|
||||
|
||||
if (!response.isOk || response.payload is! Map) {
|
||||
if (payload == null) {
|
||||
throw Exception('joinByLink: bad response');
|
||||
}
|
||||
final payload = response.payload as Map;
|
||||
|
||||
final ipRaw = payload['internalParams'];
|
||||
final ip = ipRaw is String
|
||||
? jsonDecode(ipRaw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = ip['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw Exception('joinByLink: no endpoint');
|
||||
}
|
||||
|
||||
final id = ip['id'];
|
||||
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
|
||||
final parsed = _parseCallerEndpoint(
|
||||
payload,
|
||||
'internalParams',
|
||||
context: 'joinByLink',
|
||||
);
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? '',
|
||||
endpoint: endpoint,
|
||||
callsUserId: callsUserId,
|
||||
endpoint: parsed.endpoint,
|
||||
callsUserId: parsed.callsUserId,
|
||||
peerExternalId: 0,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
|
||||
static String _uuidV4() {
|
||||
final r = Random();
|
||||
final b = List<int>.generate(16, (_) => r.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String hex(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
final s = List.generate(16, hex).join();
|
||||
return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}'
|
||||
'-${s.substring(16, 20)}-${s.substring(20)}';
|
||||
}
|
||||
|
||||
/// Fetch call history from opcode 79
|
||||
Future<List<CallLogEntry>> fetchHistory(
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
) async {
|
||||
final response = await _api.sendRequest(Opcode.videoChatHistory, {});
|
||||
if (!response.isOk || response.payload is! Map) return [];
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatHistory, {});
|
||||
if (payload == null) return [];
|
||||
|
||||
final payload = response.payload as Map<dynamic, dynamic>;
|
||||
return parseHistoryPayload(
|
||||
payload,
|
||||
accountId,
|
||||
@@ -224,19 +222,19 @@ class CallsModule {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('resolveContacts: $e');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<bool> deleteHistory(List<int> historyIds) async {
|
||||
if (historyIds.isEmpty) return true;
|
||||
final response = await _api.sendRequest(Opcode.videoChatDeleteHistory, {
|
||||
return _api.sendRequestOk(Opcode.videoChatDeleteHistory, {
|
||||
'historyIds': historyIds,
|
||||
});
|
||||
return response.isOk;
|
||||
}
|
||||
|
||||
/// Парсинг истории звонков (opcode 79: videoChatHistory)
|
||||
static Future<List<CallLogEntry>> parseHistoryPayload(
|
||||
Map<dynamic, dynamic> payload,
|
||||
int accountId,
|
||||
@@ -249,8 +247,7 @@ class CallsModule {
|
||||
final recentContacts = await ContactsModule.getContacts(accountId);
|
||||
final contactsMap = {for (final c in recentContacts) c.id: c};
|
||||
|
||||
final parsed =
|
||||
<({int peerId, CallStatus status, int time, String id})>[];
|
||||
final parsed = <({int peerId, CallStatus status, int time, String id})>[];
|
||||
|
||||
for (final item in history.whereType<Map>()) {
|
||||
final msg = item['message'];
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'chat_preview.dart';
|
||||
import 'chats.dart';
|
||||
|
||||
Map<int, Map<dynamic, dynamic>> buildContactsMap(dynamic contacts) {
|
||||
if (contacts is! List) return {};
|
||||
final result = <int, Map<dynamic, dynamic>>{};
|
||||
for (final c in contacts.whereType<Map>()) {
|
||||
final id = c['id'];
|
||||
if (id is int) result[id] = c.cast();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
CachedChat? parseChatRow(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
Map<int, Map<dynamic, dynamic>> contactsMap,
|
||||
Map<dynamic, dynamic> chatsConfig,
|
||||
Map<dynamic, dynamic> presenceMap,
|
||||
Map<int, CachedChat> existing,
|
||||
int cachedAt,
|
||||
) {
|
||||
try {
|
||||
final id = chat['id'];
|
||||
if (id is! int) return null;
|
||||
|
||||
final type = (chat['type'] as String?) ?? 'DIALOG';
|
||||
final otherId = type == 'DIALOG'
|
||||
? _otherParticipantId(chat['participants'], currentUserId)
|
||||
: null;
|
||||
|
||||
final titleIcon = _resolveTitleAndIcon(
|
||||
chat,
|
||||
id,
|
||||
type,
|
||||
otherId,
|
||||
contactsMap,
|
||||
existing,
|
||||
);
|
||||
final lastMessage = _resolveLastMessage(chat['lastMessage']);
|
||||
final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing);
|
||||
final presence = _resolvePresence(type, otherId, presenceMap);
|
||||
final adminsOwner = _resolveAdmins(chat);
|
||||
|
||||
return CachedChat(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
type: type,
|
||||
title: titleIcon.title,
|
||||
iconUrl: titleIcon.iconUrl,
|
||||
lastMsgId: lastMessage.id,
|
||||
lastMsgTime: lastMessage.time,
|
||||
lastMsgText: lastMessage.text,
|
||||
lastMsgElements: lastMessage.elements,
|
||||
lastMsgSenderId: lastMessage.senderId,
|
||||
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
||||
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
||||
cachedAt: cachedAt,
|
||||
favIndex: muteFav.favIndex,
|
||||
dontDisturbUntil: muteFav.dontDisturbUntil,
|
||||
isOnline: presence.isOnline,
|
||||
seenTime: presence.seenTime,
|
||||
participants: parseParticipants(chat['participants']),
|
||||
options: titleIcon.options,
|
||||
owner: adminsOwner.owner,
|
||||
admins: adminsOwner.admins,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при парсинге чата: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
({String? title, String? iconUrl, Set<String> options}) _resolveTitleAndIcon(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int id,
|
||||
String type,
|
||||
int? otherId,
|
||||
Map<int, Map<dynamic, dynamic>> contactsMap,
|
||||
Map<int, CachedChat> existing,
|
||||
) {
|
||||
if (type == 'DIALOG') {
|
||||
final contact = otherId != null ? contactsMap[otherId] : null;
|
||||
if (contact != null) {
|
||||
Set<String> options = const {};
|
||||
final contactOpts = contact['options'];
|
||||
if (contactOpts is List) {
|
||||
options = contactOpts.whereType<String>().toSet();
|
||||
}
|
||||
return (
|
||||
title: _nameFromContact(contact),
|
||||
iconUrl: contact['baseUrl'] as String?,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
return (
|
||||
title: existing[id]?.title,
|
||||
iconUrl: existing[id]?.iconUrl,
|
||||
options: existing[id]?.options ?? const {},
|
||||
);
|
||||
}
|
||||
Set<String> options = const {};
|
||||
final chatOpts = chat['options'];
|
||||
if (chatOpts is Map) {
|
||||
options = {
|
||||
for (final entry in chatOpts.entries)
|
||||
if (entry.value == true && entry.key is String) entry.key as String,
|
||||
};
|
||||
}
|
||||
return (
|
||||
title: chat['title'] as String?,
|
||||
iconUrl: chat['baseIconUrl'] as String?,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
|
||||
({int? id, int? time, String? text, String? elements, int? senderId})
|
||||
_resolveLastMessage(dynamic lastMsg) {
|
||||
if (lastMsg is! Map) {
|
||||
return (id: null, time: null, text: null, elements: null, senderId: null);
|
||||
}
|
||||
return (
|
||||
id: lastMsg['id'] as int?,
|
||||
time: lastMsg['time'] as int?,
|
||||
text: messagePreviewText(lastMsg),
|
||||
elements: messagePreviewElements(lastMsg),
|
||||
senderId: lastMsg['sender'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite(
|
||||
Map<dynamic, dynamic> chatsConfig,
|
||||
int id,
|
||||
Map<int, CachedChat> existing,
|
||||
) {
|
||||
final config = chatsConfig[id.toString()] ?? chatsConfig[id];
|
||||
if (config is Map) {
|
||||
return (
|
||||
favIndex: config['favIndex'] as int?,
|
||||
dontDisturbUntil: (config['dontDisturbUntil'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
final ex = existing[id];
|
||||
if (ex != null) {
|
||||
return (favIndex: ex.favIndex, dontDisturbUntil: ex.dontDisturbUntil);
|
||||
}
|
||||
return (favIndex: null, dontDisturbUntil: 0);
|
||||
}
|
||||
|
||||
({int seenTime, bool isOnline}) _resolvePresence(
|
||||
String type,
|
||||
int? otherId,
|
||||
Map<dynamic, dynamic> presenceMap,
|
||||
) {
|
||||
if (type != 'DIALOG' || otherId == null) {
|
||||
return (seenTime: 0, isOnline: false);
|
||||
}
|
||||
final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId];
|
||||
if (presence is Map) {
|
||||
return (
|
||||
seenTime: (presence['seen'] as int?) ?? 0,
|
||||
isOnline: (presence['status'] as int?) == 1,
|
||||
);
|
||||
}
|
||||
return (seenTime: 0, isOnline: false);
|
||||
}
|
||||
|
||||
({int? owner, Set<int> admins}) _resolveAdmins(Map<dynamic, dynamic> chat) {
|
||||
int? owner;
|
||||
final ownerRaw = chat['owner'];
|
||||
if (ownerRaw is int) {
|
||||
owner = ownerRaw;
|
||||
} else if (ownerRaw is String) {
|
||||
owner = int.tryParse(ownerRaw);
|
||||
}
|
||||
|
||||
Set<int> admins = const {};
|
||||
final adminsRaw = chat['admins'];
|
||||
if (adminsRaw is List) {
|
||||
admins = adminsRaw
|
||||
.map((e) => e is int ? e : int.tryParse(e.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
} else {
|
||||
final adminParticipants = chat['adminParticipants'];
|
||||
if (adminParticipants is Map) {
|
||||
admins = adminParticipants.keys
|
||||
.map((k) => k is int ? k : int.tryParse(k.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
return (owner: owner, admins: admins);
|
||||
}
|
||||
|
||||
int? _otherParticipantId(dynamic participants, int currentUserId) {
|
||||
if (participants is! Map) return null;
|
||||
for (final key in participants.keys) {
|
||||
final id = key is int ? key : int.tryParse(key.toString());
|
||||
if (id != null && id != currentUserId) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _nameFromContact(Map<dynamic, dynamic> contact) {
|
||||
final names = contact['names'];
|
||||
if (names is! List || names.isEmpty) return null;
|
||||
final nameRaw = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
||||
);
|
||||
if (nameRaw is! Map) return null;
|
||||
final name = nameRaw;
|
||||
return name['name'] as String?;
|
||||
}
|
||||
|
||||
List<ChatSearchHit> parseSearchResult(dynamic payload) {
|
||||
final result = (payload as Map?)?['result'];
|
||||
if (result is! List) return const [];
|
||||
final hits = <ChatSearchHit>[];
|
||||
for (final item in result) {
|
||||
if (item is! Map) continue;
|
||||
final chat = item['chat'];
|
||||
if (chat is! Map) continue;
|
||||
final id = chat['id'];
|
||||
if (id is! int) continue;
|
||||
final last = chat['lastMessage'];
|
||||
final link = chat['link'];
|
||||
hits.add(
|
||||
ChatSearchHit(
|
||||
id: id,
|
||||
type: (chat['type'] as String?) ?? 'CHAT',
|
||||
title: chat['title'] as String?,
|
||||
avatarUrl: chat['baseIconUrl'] as String?,
|
||||
subtitle: link is String && link.isNotEmpty
|
||||
? '@$link'
|
||||
: (last is Map ? last['text'] as String? : null),
|
||||
),
|
||||
);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
List<MessageSearchHit> parseMessageResult(dynamic payload) {
|
||||
final result = (payload as Map?)?['result'];
|
||||
if (result is! List) return const [];
|
||||
final hits = <MessageSearchHit>[];
|
||||
for (final item in result) {
|
||||
if (item is! Map) continue;
|
||||
final message = item['message'];
|
||||
if (message is! Map) continue;
|
||||
final chatId = item['chatId'];
|
||||
if (chatId is! int || chatId == 0) continue;
|
||||
hits.add(
|
||||
MessageSearchHit(
|
||||
chatId: chatId,
|
||||
messageId: message['id']?.toString(),
|
||||
text: message['text'] as String?,
|
||||
time: (message['time'] as int?) ?? 0,
|
||||
senderId: (message['sender'] as int?) ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
bool sameChatContent(CachedChat a, CachedChat b) {
|
||||
if (a.title != b.title) return false;
|
||||
if (a.iconUrl != b.iconUrl) return false;
|
||||
if (a.owner != b.owner) return false;
|
||||
if (a.dontDisturbUntil != b.dontDisturbUntil) return false;
|
||||
if (a.favIndex != b.favIndex) return false;
|
||||
if (a.lastMsgId != b.lastMsgId) return false;
|
||||
if (a.lastMsgTime != b.lastMsgTime) return false;
|
||||
if (a.lastMsgText != b.lastMsgText) return false;
|
||||
if (a.lastMsgElements != b.lastMsgElements) return false;
|
||||
if (a.lastMsgSenderId != b.lastMsgSenderId) return false;
|
||||
if (a.unreadCount != b.unreadCount) return false;
|
||||
if (a.lastEventTime != b.lastEventTime) return false;
|
||||
if (a.isOnline != b.isOnline) return false;
|
||||
if (a.seenTime != b.seenTime) return false;
|
||||
if (a.admins.length != b.admins.length) return false;
|
||||
if (!a.admins.containsAll(b.admins)) return false;
|
||||
if (a.options.length != b.options.length) return false;
|
||||
if (!a.options.containsAll(b.options)) return false;
|
||||
if (a.participants.length != b.participants.length) return false;
|
||||
for (final e in a.participants.entries) {
|
||||
if (b.participants[e.key] != e.value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:convert';
|
||||
|
||||
String? attachPreviewLabel(dynamic attaches) {
|
||||
if (attaches is! List || attaches.isEmpty) return null;
|
||||
final first = attaches.first;
|
||||
if (first is! Map) return null;
|
||||
final type = (first['_type'] as String? ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case 'PHOTO':
|
||||
return 'Фото';
|
||||
case 'VIDEO':
|
||||
return 'Видео';
|
||||
case 'AUDIO':
|
||||
return 'Голосовое сообщение';
|
||||
case 'FILE':
|
||||
final name = first['name']?.toString();
|
||||
return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл';
|
||||
case 'STICKER':
|
||||
return 'Стикер';
|
||||
case 'SHARE':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка';
|
||||
case 'POLL':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос';
|
||||
case 'LOCATION':
|
||||
return 'Геопозиция';
|
||||
case 'CONTACT':
|
||||
return 'Контакт';
|
||||
case 'CONTROL':
|
||||
return _controlPreviewLabel(first);
|
||||
case 'INLINE_KEYBOARD':
|
||||
return null;
|
||||
case 'CALL':
|
||||
final video = first['callType']?.toString().toUpperCase() == 'VIDEO';
|
||||
final dur = (first['duration'] as num?)?.toInt() ?? 0;
|
||||
final hangup = first['hangupType']?.toString();
|
||||
final failed =
|
||||
dur == 0 ||
|
||||
hangup == 'CANCELED' ||
|
||||
hangup == 'REJECTED' ||
|
||||
hangup == 'MISSED';
|
||||
if (first['joinLink'] != null) {
|
||||
return video ? 'Групповой видеозвонок' : 'Групповой звонок';
|
||||
}
|
||||
if (failed) {
|
||||
return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок';
|
||||
}
|
||||
return video ? 'Видеозвонок' : 'Звонок';
|
||||
default:
|
||||
return 'Вложение';
|
||||
}
|
||||
}
|
||||
|
||||
String? _controlPreviewLabel(Map c) {
|
||||
final title = c['title']?.toString();
|
||||
if (title != null && title.isNotEmpty) return title;
|
||||
final short = c['shortMessage']?.toString();
|
||||
if (short != null && short.isNotEmpty) return short;
|
||||
switch (c['event']?.toString()) {
|
||||
case 'new':
|
||||
return 'Чат создан';
|
||||
case 'add':
|
||||
case 'joinByLink':
|
||||
return 'Новый участник';
|
||||
case 'leave':
|
||||
return 'Участник вышел';
|
||||
case 'remove':
|
||||
return 'Участник удалён';
|
||||
case 'pin':
|
||||
return 'Закреплённое сообщение';
|
||||
case 'changeTitle':
|
||||
return 'Название чата изменено';
|
||||
case 'changeIcon':
|
||||
return 'Фото чата обновлено';
|
||||
default:
|
||||
return 'Системное сообщение';
|
||||
}
|
||||
}
|
||||
|
||||
String? messagePreviewText(Map msg) {
|
||||
final link = msg['link'];
|
||||
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
|
||||
final original = link['message'];
|
||||
final inner = original is Map ? _bodyPreviewText(original) : null;
|
||||
return inner != null && inner.isNotEmpty
|
||||
? '↪ $inner'
|
||||
: '↪ Пересланное сообщение';
|
||||
}
|
||||
return _bodyPreviewText(msg);
|
||||
}
|
||||
|
||||
String? _bodyPreviewText(Map msg) {
|
||||
final text = msg['text']?.toString();
|
||||
if (text != null && text.isNotEmpty) return text;
|
||||
return attachPreviewLabel(msg['attaches']);
|
||||
}
|
||||
|
||||
String? messagePreviewElements(Map msg) {
|
||||
final text = msg['text'];
|
||||
if (text is! String || text.isEmpty) return null;
|
||||
final elements = msg['elements'];
|
||||
if (elements is List && elements.isNotEmpty) return jsonEncode(elements);
|
||||
return null;
|
||||
}
|
||||
+276
-542
File diff suppressed because it is too large
Load Diff
@@ -82,23 +82,27 @@ class CloudStorageModule {
|
||||
}
|
||||
|
||||
static Future<void> _configurePrivacy(Api api, int chatId) async {
|
||||
await Future.wait([
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_ADD_MEMBER': true}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ALL_CAN_PIN_MESSAGE': false}),
|
||||
ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_CALL': true}),
|
||||
]);
|
||||
await chats.setChatOptions(
|
||||
api,
|
||||
chatId: chatId,
|
||||
options: {
|
||||
'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true,
|
||||
'ONLY_ADMIN_CAN_ADD_MEMBER': true,
|
||||
'ALL_CAN_PIN_MESSAGE': false,
|
||||
'ONLY_ADMIN_CAN_CALL': true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CachedChat?> setupEnv(Api api) async {
|
||||
final temp = await ChatsModule.createGroupChat(
|
||||
final temp = await chats.createGroupChat(
|
||||
api,
|
||||
title: _tempName,
|
||||
userIds: [],
|
||||
);
|
||||
if (temp == null) return null;
|
||||
final name = '$_prefix${_computeSpecialNumber(temp.id)}';
|
||||
final ok = await ChatsModule.setChatTitle(api, chatId: temp.id, title: name);
|
||||
final ok = await chats.setChatTitle(api, chatId: temp.id, title: name);
|
||||
if (!ok) return null;
|
||||
await _configurePrivacy(api, temp.id);
|
||||
return temp;
|
||||
@@ -107,12 +111,34 @@ class CloudStorageModule {
|
||||
// Turns an orphan "Облачное хранилище" group into a valid env group
|
||||
static Future<CachedChat?> repairOrphan(Api api, CachedChat orphan) async {
|
||||
final name = '$_prefix${_computeSpecialNumber(orphan.id)}';
|
||||
final ok = await ChatsModule.setChatTitle(api, chatId: orphan.id, title: name);
|
||||
final ok = await chats.setChatTitle(api, chatId: orphan.id, title: name);
|
||||
if (!ok) return null;
|
||||
await _configurePrivacy(api, orphan.id);
|
||||
return orphan;
|
||||
}
|
||||
|
||||
static Iterable<CloudFile> _cloudFilesFrom(
|
||||
Iterable<CachedMessage> msgs,
|
||||
int chatId,
|
||||
int accountId,
|
||||
) sync* {
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
yield CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CloudFile>> fetchFiles(
|
||||
MessagesModule messages,
|
||||
int accountId,
|
||||
@@ -120,23 +146,7 @@ class CloudStorageModule {
|
||||
int count = 200,
|
||||
}) async {
|
||||
final msgs = await messages.fetchHistory(accountId, chatId, count: count);
|
||||
final files = <CloudFile>[];
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
files.add(CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
return _cloudFilesFrom(msgs, chatId, accountId).toList();
|
||||
}
|
||||
|
||||
// Fetches only the last few messages to find a newly uploaded file — avoids full 200-msg reload
|
||||
@@ -147,21 +157,9 @@ class CloudStorageModule {
|
||||
int? expectedFileId,
|
||||
}) async {
|
||||
final msgs = await messages.fetchHistory(accountId, chatId, count: 5);
|
||||
for (final msg in msgs) {
|
||||
for (final a in msg.attachments ?? []) {
|
||||
if (a is FileAttachment && a.name != null) {
|
||||
if (expectedFileId == null || a.fileId == expectedFileId) {
|
||||
return CloudFile(
|
||||
name: a.name!,
|
||||
size: a.size,
|
||||
time: msg.time,
|
||||
fileId: a.fileId,
|
||||
messageId: msg.id,
|
||||
chatId: chatId,
|
||||
accountId: accountId,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final file in _cloudFilesFrom(msgs, chatId, accountId)) {
|
||||
if (expectedFileId == null || file.fileId == expectedFileId) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -11,14 +11,15 @@ class ComplaintReason {
|
||||
class ComplaintsModule {
|
||||
static Map<int, List<ComplaintReason>>? _cache;
|
||||
|
||||
static void clear() => _cache = null;
|
||||
|
||||
static Future<Map<int, List<ComplaintReason>>> fetchReasons(Api api) async {
|
||||
final cached = _cache;
|
||||
if (cached != null) return cached;
|
||||
|
||||
final response = await api.sendRequest(
|
||||
Opcode.complainReasonsGet,
|
||||
{'complainSync': 0},
|
||||
);
|
||||
final response = await api.sendRequest(Opcode.complainReasonsGet, {
|
||||
'complainSync': 0,
|
||||
});
|
||||
if (!response.isOk) return cached ?? const {};
|
||||
|
||||
final payload = response.payload;
|
||||
|
||||
@@ -56,8 +56,9 @@ class DigitalIdModule {
|
||||
final hashIndex = url.indexOf('#');
|
||||
if (hashIndex < 0) return null;
|
||||
final fragment = url.substring(hashIndex + 1);
|
||||
final match = RegExp(r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)')
|
||||
.firstMatch(fragment);
|
||||
final match = RegExp(
|
||||
r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)',
|
||||
).firstMatch(fragment);
|
||||
final raw = match?.group(1);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return Uri.decodeComponent(raw);
|
||||
@@ -84,8 +85,7 @@ class DigitalIdModule {
|
||||
Future<String> _generateDeviceId() async {
|
||||
final rnd = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||
final hex =
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
@@ -162,7 +162,8 @@ class DigitalIdModule {
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map) {
|
||||
final rawCode = decoded['code'] ?? decoded['error'] ?? decoded['status'];
|
||||
final rawCode =
|
||||
decoded['code'] ?? decoded['error'] ?? decoded['status'];
|
||||
if (rawCode is String && rawCode.isNotEmpty) code = rawCode;
|
||||
final rawMessage = decoded['message'] ?? decoded['error_description'];
|
||||
if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage;
|
||||
@@ -191,26 +192,26 @@ class DigitalIdModule {
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/create-biometry-token',
|
||||
body: {
|
||||
'device_id': deviceId,
|
||||
'photo_hash': ?photoHash,
|
||||
},
|
||||
body: {'device_id': deviceId, 'photo_hash': ?photoHash},
|
||||
);
|
||||
return _unwrapData(decoded)['token'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<String> refreshUserDocs(String token) async {
|
||||
final decoded =
|
||||
await _send('POST', '/v3/digital-id/refresh-user-docs', body: {
|
||||
'token': token,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/refresh-user-docs',
|
||||
body: {'token': token},
|
||||
);
|
||||
return _unwrapData(decoded)['state'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<DigitalIdUserDocs?> getUserDocs(String state) async {
|
||||
final decoded = await _send('POST', '/v2/digital-id/get-user-docs', body: {
|
||||
'state': state,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v2/digital-id/get-user-docs',
|
||||
body: {'state': state},
|
||||
);
|
||||
if (decoded is Map && decoded['status'] == 'done') {
|
||||
final data = decoded['data'];
|
||||
if (data is Map) return DigitalIdUserDocs.fromMap(data);
|
||||
@@ -227,19 +228,22 @@ class DigitalIdModule {
|
||||
required String deviceId,
|
||||
String? photoHash,
|
||||
}) async {
|
||||
final decoded = await _send('POST', '/digital-id-verify-photo', body: {
|
||||
'device_id': deviceId,
|
||||
'photo_hash': ?photoHash,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/digital-id-verify-photo',
|
||||
body: {'device_id': deviceId, 'photo_hash': ?photoHash},
|
||||
);
|
||||
final status = decoded is Map ? decoded['status'] as String? : null;
|
||||
return DigitalIdVerification.fromValue(status);
|
||||
}
|
||||
|
||||
Future<bool> shadowMode(String deviceId) async {
|
||||
try {
|
||||
final decoded = await _send('POST', '/v3/digital-id/shadow-mode', body: {
|
||||
'device_id': deviceId,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/shadow-mode',
|
||||
body: {'device_id': deviceId},
|
||||
);
|
||||
return _unwrapData(decoded)['shadow_mode'] == true;
|
||||
} on DigitalIdException catch (e) {
|
||||
if (e.code == 'HTTP_404') return false;
|
||||
@@ -252,9 +256,11 @@ class DigitalIdModule {
|
||||
}
|
||||
|
||||
Future<DigitalIdUniversalQr> userQr(String token) async {
|
||||
final decoded = await _send('POST', '/v3/digital-id/user-qr', body: {
|
||||
'token': token,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/user-qr',
|
||||
body: {'token': token},
|
||||
);
|
||||
return DigitalIdUniversalQr.fromMap(_unwrapData(decoded));
|
||||
}
|
||||
|
||||
@@ -264,12 +270,16 @@ class DigitalIdModule {
|
||||
required DigitalIdQrType qrType,
|
||||
String? kidAct,
|
||||
}) async {
|
||||
final decoded = await _send('POST', '/v3/digital-id/generate-qr', body: {
|
||||
'photo': photo,
|
||||
'token': token,
|
||||
'qr_type': qrType.code,
|
||||
'kid_act': ?kidAct,
|
||||
});
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/generate-qr',
|
||||
body: {
|
||||
'photo': photo,
|
||||
'token': token,
|
||||
'qr_type': qrType.code,
|
||||
'kid_act': ?kidAct,
|
||||
},
|
||||
);
|
||||
return DigitalIdQr.fromMap(_unwrapData(decoded));
|
||||
}
|
||||
|
||||
@@ -277,14 +287,9 @@ class DigitalIdModule {
|
||||
String? passStatus,
|
||||
String? inn,
|
||||
}) async {
|
||||
final query = <String, String>{
|
||||
'pass_status': ?passStatus,
|
||||
'inn': ?inn,
|
||||
};
|
||||
final suffix =
|
||||
query.isEmpty ? '' : '?${Uri(queryParameters: query).query}';
|
||||
final decoded =
|
||||
await _send('GET', '/v2/digital-id/get-cards-list$suffix');
|
||||
final query = <String, String>{'pass_status': ?passStatus, 'inn': ?inn};
|
||||
final suffix = query.isEmpty ? '' : '?${Uri(queryParameters: query).query}';
|
||||
final decoded = await _send('GET', '/v2/digital-id/get-cards-list$suffix');
|
||||
final cards = _unwrapData(decoded)['acms_cards'];
|
||||
if (cards is! List) return const [];
|
||||
return cards
|
||||
@@ -294,17 +299,19 @@ class DigitalIdModule {
|
||||
}
|
||||
|
||||
Future<void> activateAcms({required String id, required String inn}) async {
|
||||
await _send('POST', '/v2/digital-id/activate-acms', body: {
|
||||
'id': id,
|
||||
'inn': inn,
|
||||
'pass_status': 'active',
|
||||
});
|
||||
await _send(
|
||||
'POST',
|
||||
'/v2/digital-id/activate-acms',
|
||||
body: {'id': id, 'inn': inn, 'pass_status': 'active'},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createLiteProfile(String deviceId) async {
|
||||
await _send('POST', '/v2/digital-id/create-lite-profile', body: {
|
||||
'device_id': deviceId,
|
||||
});
|
||||
await _send(
|
||||
'POST',
|
||||
'/v2/digital-id/create-lite-profile',
|
||||
body: {'device_id': deviceId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteProfile() async {
|
||||
|
||||
@@ -42,6 +42,9 @@ class UploadError extends UploadEvent {
|
||||
}
|
||||
|
||||
class FileUploader {
|
||||
static const String _userAgentHeader =
|
||||
'OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)';
|
||||
|
||||
final Api api;
|
||||
final MessagesModule messages;
|
||||
|
||||
@@ -87,36 +90,24 @@ class FileUploader {
|
||||
}());
|
||||
|
||||
final uri = Uri.parse(info.url);
|
||||
socket = await _openSocket(uri);
|
||||
if (cancelled) return;
|
||||
|
||||
_writeHeaders(socket!, uri, filename, totalSize);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (stopwatch.elapsed >= progressThrottle) {
|
||||
ctrl.add(UploadProgress(sent: sent, total: totalSize));
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket!.addStream(body);
|
||||
await socket!.flush();
|
||||
if (cancelled) return;
|
||||
ctrl.add(UploadProgress(sent: totalSize, total: totalSize));
|
||||
|
||||
final statusCode = await _readResponse(
|
||||
socket!,
|
||||
final result = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildUploadHeaders(uri, filename, totalSize),
|
||||
bodyStream: file.openRead(),
|
||||
progressTotal: totalSize,
|
||||
onProgress: (sent, total) {
|
||||
if (!cancelled) ctrl.add(UploadProgress(sent: sent, total: total));
|
||||
},
|
||||
progressThrottle: progressThrottle,
|
||||
autoForceAfter: autoForceAfter,
|
||||
overallTimeout: overallTimeout,
|
||||
timeout: overallTimeout,
|
||||
onSocketReady: (s) => socket = s,
|
||||
shouldAbort: () => cancelled,
|
||||
);
|
||||
try {
|
||||
socket!.destroy();
|
||||
} catch (_) {}
|
||||
if (cancelled) return;
|
||||
|
||||
final statusCode = result?.$1 ?? 0;
|
||||
if (statusCode != 200 && statusCode != 0) {
|
||||
ctrl.add(UploadError('http_$statusCode'));
|
||||
return;
|
||||
@@ -134,13 +125,15 @@ class FileUploader {
|
||||
return;
|
||||
}
|
||||
|
||||
ctrl.add(UploadDone(
|
||||
fileId: info.fileId,
|
||||
token: info.token,
|
||||
url: info.url,
|
||||
filename: filename,
|
||||
size: totalSize,
|
||||
));
|
||||
ctrl.add(
|
||||
UploadDone(
|
||||
fileId: info.fileId,
|
||||
token: info.token,
|
||||
url: info.url,
|
||||
filename: filename,
|
||||
size: totalSize,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!cancelled) ctrl.add(UploadError(e.toString()));
|
||||
} finally {
|
||||
@@ -155,11 +148,6 @@ class FileUploader {
|
||||
return ctrl.stream;
|
||||
}
|
||||
|
||||
/// Загружает медиа (Ogg/Opus аудио или MP4 видеосообщение) на CDN-URL,
|
||||
/// полученный из [MessagesModule.requestAudioUploadUrl] /
|
||||
/// [MessagesModule.requestVideoNoteUploadUrl]. Одиночный POST всего файла
|
||||
/// (`octet-stream`, `Content-Range` на весь объём, `filename=<число>`).
|
||||
/// Токен уже известен, поэтому возвращается только признак успеха.
|
||||
Future<bool> uploadMediaFile(
|
||||
Uri uri,
|
||||
File file, {
|
||||
@@ -167,43 +155,30 @@ class FileUploader {
|
||||
Duration overallTimeout = const Duration(minutes: 5),
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
final total = await file.length();
|
||||
if (total <= 0) return false;
|
||||
final filename =
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
final filename = _syntheticFilename();
|
||||
|
||||
socket = await _openSocket(uri);
|
||||
_writeHeaders(
|
||||
socket,
|
||||
final result = await _sendHttpRequest(
|
||||
uri,
|
||||
filename,
|
||||
total,
|
||||
contentType: 'application/octet-stream',
|
||||
connection: 'close',
|
||||
method: 'POST',
|
||||
headers: _buildUploadHeaders(
|
||||
uri,
|
||||
filename,
|
||||
total,
|
||||
contentType: 'application/octet-stream',
|
||||
connection: 'close',
|
||||
),
|
||||
bodyStream: file.openRead(),
|
||||
progressTotal: total,
|
||||
onProgress: onProgress,
|
||||
progressThrottle: progressThrottle,
|
||||
timeout: overallTimeout,
|
||||
);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (onProgress != null && stopwatch.elapsed >= progressThrottle) {
|
||||
onProgress(sent, total);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket.addStream(body);
|
||||
await socket.flush();
|
||||
onProgress?.call(total, total);
|
||||
|
||||
final response = await _readFullResponse(socket, timeout: overallTimeout);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
final statusCode = response?.$1 ?? 0;
|
||||
final respBody = response?.$2 ?? '';
|
||||
final statusCode = result?.$1 ?? 0;
|
||||
final respBody = result?.$2 ?? '';
|
||||
logger.w(
|
||||
'uploadMediaFile: status=$statusCode total=$total '
|
||||
'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}',
|
||||
@@ -213,9 +188,6 @@ class FileUploader {
|
||||
return statusCode == 200 && !hasError;
|
||||
} catch (e) {
|
||||
logger.w('uploadMediaFile: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -228,39 +200,49 @@ class FileUploader {
|
||||
if (uri.scheme != 'https') return base;
|
||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||
if (allowInsecure) {
|
||||
logger.w('TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM');
|
||||
return SecureSocket.secure(base, host: uri.host, onBadCertificate: (_) => true);
|
||||
logger.w(
|
||||
'TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM',
|
||||
);
|
||||
return SecureSocket.secure(
|
||||
base,
|
||||
host: uri.host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
}
|
||||
return SecureSocket.secure(base, host: uri.host);
|
||||
}
|
||||
|
||||
void _writeHeaders(
|
||||
Socket socket,
|
||||
String _syntheticFilename() =>
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
|
||||
String _multipartBoundary() =>
|
||||
'----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
|
||||
Map<String, String> _buildUploadHeaders(
|
||||
Uri uri,
|
||||
String filename,
|
||||
int total, {
|
||||
String contentType = 'application/x-binary; charset=x-user-defined',
|
||||
String connection = 'keep-alive',
|
||||
}) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: $contentType\r\n')
|
||||
..write('Content-Disposition: attachment; filename=$filename\r\n')
|
||||
..write('Connection: $connection\r\n')
|
||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||
..write('Content-Range: bytes 0-${total - 1}/$total\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
return {
|
||||
'Host': uri.host,
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': 'attachment; filename=$filename',
|
||||
'Connection': connection,
|
||||
'User-Agent': Uri.encodeComponent(_userAgentHeader),
|
||||
'Content-Range': 'bytes 0-${total - 1}/$total',
|
||||
'Content-Length': '$total',
|
||||
};
|
||||
}
|
||||
|
||||
Future<String?> uploadImage(Uri uri, Uint8List bytes, {String filename = 'avatar.jpg'}) async {
|
||||
Socket? socket;
|
||||
Future<String?> uploadImage(
|
||||
Uri uri,
|
||||
Uint8List bytes, {
|
||||
String filename = 'avatar.jpg',
|
||||
}) async {
|
||||
try {
|
||||
socket = await _openSocket(uri);
|
||||
final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
final boundary = _multipartBoundary();
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
@@ -268,43 +250,40 @@ class FileUploader {
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
uri,
|
||||
preamble.length + bytes.length + epilogue.length,
|
||||
boundary: boundary,
|
||||
);
|
||||
socket.add(preamble);
|
||||
socket.add(bytes);
|
||||
socket.add(epilogue);
|
||||
await socket.flush();
|
||||
|
||||
final response = await _readFullResponse(
|
||||
socket,
|
||||
final response = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildMultipartHeaders(
|
||||
uri,
|
||||
preamble.length + bytes.length + epilogue.length,
|
||||
boundary: boundary,
|
||||
),
|
||||
prefixBytes: preamble,
|
||||
bodyStream: Stream.value(bytes),
|
||||
suffixBytes: epilogue,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
final (status, body) = response;
|
||||
if (status != 200) {
|
||||
logger.w('uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
logger.w(
|
||||
'uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final token = _parsePhotoToken(body);
|
||||
if (token == null) {
|
||||
logger.w('uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
logger.w(
|
||||
'uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}',
|
||||
);
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadImage: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -316,12 +295,9 @@ class FileUploader {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
final fileLength = await file.length();
|
||||
socket = await _openSocket(uri);
|
||||
final boundary =
|
||||
'----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
final boundary = _multipartBoundary();
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
@@ -329,36 +305,23 @@ class FileUploader {
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
|
||||
final response = await _sendHttpRequest(
|
||||
uri,
|
||||
preamble.length + fileLength + epilogue.length,
|
||||
boundary: boundary,
|
||||
);
|
||||
socket.add(preamble);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (onProgress != null && stopwatch.elapsed >= progressThrottle) {
|
||||
onProgress(sent, fileLength);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket.addStream(body);
|
||||
socket.add(epilogue);
|
||||
await socket.flush();
|
||||
onProgress?.call(fileLength, fileLength);
|
||||
|
||||
final response = await _readFullResponse(
|
||||
socket,
|
||||
method: 'POST',
|
||||
headers: _buildMultipartHeaders(
|
||||
uri,
|
||||
preamble.length + fileLength + epilogue.length,
|
||||
boundary: boundary,
|
||||
),
|
||||
prefixBytes: preamble,
|
||||
bodyStream: file.openRead(),
|
||||
suffixBytes: epilogue,
|
||||
progressTotal: fileLength,
|
||||
onProgress: onProgress,
|
||||
progressThrottle: progressThrottle,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) return null;
|
||||
final (status, responseBody) = response;
|
||||
@@ -369,20 +332,10 @@ class FileUploader {
|
||||
return _parsePhotoToken(responseBody);
|
||||
} catch (e) {
|
||||
logger.w('uploadPhoto: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает видео на CDN-URL (vu.okcdn.ru/upload.do), полученный из
|
||||
/// [MessagesModule.requestVideoUploadUrl], по протоколу OK с докачкой:
|
||||
/// сначала GET-хендшейк (возвращает уже загруженный оффсет), затем
|
||||
/// параллельная отправка чанков по [chunkSize] байт через `Content-Range`
|
||||
/// ([concurrency] одновременных соединений, режим `X-Uploading-Mode:
|
||||
/// parallel`). Токен уже известен, поэтому возвращается только признак
|
||||
/// успеха.
|
||||
Future<bool> uploadVideoFile(
|
||||
Uri uri,
|
||||
File file, {
|
||||
@@ -394,8 +347,7 @@ class FileUploader {
|
||||
final total = await file.length();
|
||||
if (total <= 0) return false;
|
||||
|
||||
final fileName =
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
final fileName = _syntheticFilename();
|
||||
|
||||
final handshake = await _okCdnRequest(
|
||||
uri,
|
||||
@@ -470,56 +422,140 @@ class FileUploader {
|
||||
String? contentRange,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
socket = await _openSocket(uri);
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('$method $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
|
||||
..write('Content-Disposition: attachment; fileName="$fileName"\r\n');
|
||||
if (contentRange != null) {
|
||||
headers.write('Content-Range: $contentRange\r\n');
|
||||
}
|
||||
headers
|
||||
..write('Content-Length: ${body?.length ?? 0}\r\n')
|
||||
..write('X-Uploading-Mode: parallel\r\n')
|
||||
..write('Connection: close\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
if (body != null && body.isNotEmpty) socket.add(body);
|
||||
await socket.flush();
|
||||
|
||||
final response = await _readFullResponse(socket, timeout: timeout);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
return response;
|
||||
final headers = {
|
||||
'Host': uri.host,
|
||||
'Content-Type': 'application/x-binary; charset=x-user-defined',
|
||||
'Content-Disposition': 'attachment; fileName="$fileName"',
|
||||
'Content-Range': ?contentRange,
|
||||
'Content-Length': '${body?.length ?? 0}',
|
||||
'X-Uploading-Mode': 'parallel',
|
||||
'Connection': 'close',
|
||||
};
|
||||
return await _sendHttpRequest(
|
||||
uri,
|
||||
method: method,
|
||||
headers: headers,
|
||||
prefixBytes: body,
|
||||
timeout: timeout,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('_okCdnRequest($method): $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
|
||||
Map<String, String> _buildMultipartHeaders(
|
||||
Uri uri,
|
||||
int total, {
|
||||
required String boundary,
|
||||
}) {
|
||||
return {
|
||||
'Host': uri.host,
|
||||
'Content-Type': 'multipart/form-data; boundary=$boundary',
|
||||
'Content-Length': '$total',
|
||||
'Connection': 'keep-alive',
|
||||
'User-Agent': Uri.encodeComponent(_userAgentHeader),
|
||||
};
|
||||
}
|
||||
|
||||
Future<(int, String)?> _sendHttpRequest(
|
||||
Uri uri, {
|
||||
required String method,
|
||||
required Map<String, String> headers,
|
||||
List<int>? prefixBytes,
|
||||
Stream<List<int>>? bodyStream,
|
||||
List<int>? suffixBytes,
|
||||
int? progressTotal,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
Duration? autoForceAfter,
|
||||
required Duration timeout,
|
||||
void Function(Socket socket)? onSocketReady,
|
||||
bool Function()? shouldAbort,
|
||||
}) async {
|
||||
final socket = await _openSocket(uri);
|
||||
onSocketReady?.call(socket);
|
||||
try {
|
||||
if (shouldAbort?.call() ?? false) return null;
|
||||
|
||||
_writeRequestHeaders(socket, uri, method, headers);
|
||||
if (prefixBytes != null && prefixBytes.isNotEmpty) {
|
||||
socket.add(prefixBytes);
|
||||
}
|
||||
if (bodyStream != null) {
|
||||
final stream = (onProgress != null && progressTotal != null)
|
||||
? _withProgress(
|
||||
bodyStream,
|
||||
progressTotal,
|
||||
onProgress,
|
||||
throttle: progressThrottle,
|
||||
)
|
||||
: bodyStream;
|
||||
await socket.addStream(stream);
|
||||
}
|
||||
if (suffixBytes != null && suffixBytes.isNotEmpty) {
|
||||
socket.add(suffixBytes);
|
||||
}
|
||||
await socket.flush();
|
||||
if (onProgress != null && progressTotal != null) {
|
||||
onProgress(progressTotal, progressTotal);
|
||||
}
|
||||
if (shouldAbort?.call() ?? false) return null;
|
||||
|
||||
if (autoForceAfter != null) {
|
||||
final status = await _readResponse(
|
||||
socket,
|
||||
autoForceAfter: autoForceAfter,
|
||||
overallTimeout: timeout,
|
||||
);
|
||||
return (status, '');
|
||||
}
|
||||
return await _readFullResponse(socket, timeout: timeout);
|
||||
} finally {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _writeRequestHeaders(
|
||||
Socket socket,
|
||||
Uri uri,
|
||||
String method,
|
||||
Map<String, String> headers,
|
||||
) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: multipart/form-data; boundary=$boundary\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('Connection: keep-alive\r\n')
|
||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
final buffer = StringBuffer()..write('$method $path HTTP/1.1\r\n');
|
||||
for (final entry in headers.entries) {
|
||||
buffer.write('${entry.key}: ${entry.value}\r\n');
|
||||
}
|
||||
buffer.write('\r\n');
|
||||
socket.add(utf8.encode(buffer.toString()));
|
||||
}
|
||||
|
||||
Stream<List<int>> _withProgress(
|
||||
Stream<List<int>> src,
|
||||
int total,
|
||||
void Function(int sent, int total) onProgress, {
|
||||
Duration throttle = const Duration(milliseconds: 16),
|
||||
}) {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
return src.map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (stopwatch.elapsed >= throttle) {
|
||||
onProgress(sent, total);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
}
|
||||
|
||||
String _contentTypeForFilename(String filename) {
|
||||
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
|
||||
final ext = filename.contains('.')
|
||||
? filename.split('.').last.toLowerCase()
|
||||
: '';
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
@@ -557,13 +593,17 @@ class FileUploader {
|
||||
(int, String)? tryParse({required bool atClose}) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final headerStr = utf8.decode(
|
||||
bytes.sublist(0, headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
final lines = headerStr.split('\r\n');
|
||||
final parts = lines.first.split(' ');
|
||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||
final headerLines = lines.skip(1);
|
||||
final chunked = headerLines.any(
|
||||
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
(l) =>
|
||||
l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
l.toLowerCase().contains('chunked'),
|
||||
);
|
||||
int? contentLength;
|
||||
@@ -572,12 +612,17 @@ class FileUploader {
|
||||
contentLength = int.tryParse(l.split(':').last.trim());
|
||||
}
|
||||
}
|
||||
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||
final rawBody = utf8.decode(
|
||||
bytes.sublist(headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
if (chunked) {
|
||||
if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
|
||||
return (status, _decodeChunked(rawBody));
|
||||
}
|
||||
if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) {
|
||||
if (contentLength != null &&
|
||||
!atClose &&
|
||||
bytes.length - headerEnd < contentLength) {
|
||||
return null;
|
||||
}
|
||||
return (status, rawBody);
|
||||
@@ -596,7 +641,9 @@ class FileUploader {
|
||||
onDone: () {
|
||||
final parsed = tryParse(atClose: true);
|
||||
if (parsed == null) {
|
||||
logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)');
|
||||
logger.w(
|
||||
'uploadImage: connection closed without HTTP response (${bytes.length} bytes)',
|
||||
);
|
||||
}
|
||||
finishWith(parsed);
|
||||
},
|
||||
@@ -697,7 +744,10 @@ class FileUploader {
|
||||
},
|
||||
);
|
||||
|
||||
overall = Timer(overallTimeout, () => fail(TimeoutException('Тайм-аут загрузки')));
|
||||
overall = Timer(
|
||||
overallTimeout,
|
||||
() => fail(TimeoutException('Тайм-аут загрузки')),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
@@ -705,7 +755,10 @@ class FileUploader {
|
||||
int? _parseHttpStatus(List<int> bytes) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final headerStr = utf8.decode(
|
||||
bytes.sublist(0, headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
final statusLine = headerStr.split('\r\n').first;
|
||||
final parts = statusLine.split(' ');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
@@ -25,10 +25,7 @@ class FoldersModule {
|
||||
static bool isAllChatsFolder(ChatFolder f) {
|
||||
if (f.id == 'all.chat.folder') return true;
|
||||
final t = f.title.trim().toLowerCase();
|
||||
return t == 'все' ||
|
||||
t == 'все чаты' ||
|
||||
t == 'all' ||
|
||||
t == 'all chats';
|
||||
return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats';
|
||||
}
|
||||
|
||||
static String? preferredInitialFolderId(List<ChatFolder> folders) {
|
||||
@@ -88,20 +85,42 @@ class FoldersModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<ChatFolder> _parseFolderList(
|
||||
List<dynamic> json, {
|
||||
bool lenient = true,
|
||||
}) {
|
||||
if (lenient) {
|
||||
return json
|
||||
.map((e) {
|
||||
try {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
}
|
||||
return json.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static Future<List<ChatFolder>> loadFolders(int accountId) async {
|
||||
final raw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final folders = (map['folders'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
})
|
||||
.toList() ??
|
||||
[];
|
||||
final foldersJson = map['folders'] as List<dynamic>?;
|
||||
final folders = foldersJson == null
|
||||
? <ChatFolder>[]
|
||||
: _parseFolderList(foldersJson, lenient: false);
|
||||
final order = map['foldersOrder'] as List<dynamic>?;
|
||||
sortFoldersInPlace(folders, order);
|
||||
return folders;
|
||||
@@ -135,19 +154,7 @@ class FoldersModule {
|
||||
|
||||
List<ChatFolder> folders;
|
||||
if (foldersJson != null) {
|
||||
folders = foldersJson
|
||||
.map((json) {
|
||||
try {
|
||||
final m = json is Map<String, dynamic>
|
||||
? json
|
||||
: Map<String, dynamic>.from(json as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
folders = _parseFolderList(foldersJson);
|
||||
} else {
|
||||
folders = await loadFolders(accountId);
|
||||
}
|
||||
@@ -164,19 +171,7 @@ class FoldersModule {
|
||||
final foldersJson = chatFolders['FOLDERS'] as List<dynamic>?;
|
||||
if (foldersJson == null) return;
|
||||
final order = chatFolders['foldersOrder'] as List<dynamic>?;
|
||||
final folders = foldersJson
|
||||
.map((json) {
|
||||
try {
|
||||
final m = json is Map<String, dynamic>
|
||||
? json
|
||||
: Map<String, dynamic>.from(json as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
final folders = _parseFolderList(foldersJson);
|
||||
sortFoldersInPlace(folders, order);
|
||||
await _persist(accountId, folders, order);
|
||||
await markFoldersListReady(accountId);
|
||||
@@ -196,9 +191,7 @@ class FoldersModule {
|
||||
'filters': folder.filters,
|
||||
'options': folder.options ?? const [],
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
throwIfPacketError(packet);
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return null;
|
||||
final folderJson = data['folder'];
|
||||
@@ -213,15 +206,10 @@ class FoldersModule {
|
||||
final snapshot = (currentRaw != null && currentRaw.isNotEmpty)
|
||||
? jsonDecode(currentRaw) as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
final existing = (snapshot['folders'] as List?)
|
||||
?.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
})
|
||||
.toList() ??
|
||||
<ChatFolder>[];
|
||||
final existingRaw = snapshot['folders'] as List<dynamic>?;
|
||||
final existing = existingRaw == null
|
||||
? <ChatFolder>[]
|
||||
: _parseFolderList(existingRaw, lenient: false);
|
||||
final idx = existing.indexWhere((f) => f.id == updated.id);
|
||||
if (idx >= 0) {
|
||||
existing[idx] = updated;
|
||||
@@ -238,9 +226,7 @@ class FoldersModule {
|
||||
final packet = await api.sendRequest(Opcode.foldersGet, {
|
||||
'folderSync': 0,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
throwIfPacketError(packet);
|
||||
final data = packet.payload;
|
||||
if (data is Map) {
|
||||
await applyPayload(accountId, data.cast<dynamic, dynamic>());
|
||||
|
||||
+148
-295
@@ -10,7 +10,7 @@ import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
import 'chats.dart' show chats;
|
||||
|
||||
class ContactCache {
|
||||
static final Map<int, String> _nameCache = {};
|
||||
@@ -337,6 +337,10 @@ class ReplyInfo {
|
||||
return '';
|
||||
case AttachmentType.inlineKeyboard:
|
||||
return '';
|
||||
case AttachmentType.forward:
|
||||
return 'Переслано';
|
||||
case AttachmentType.unknown:
|
||||
return 'Вложение';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -434,6 +438,28 @@ class CachedMessage {
|
||||
return list;
|
||||
}
|
||||
|
||||
static (List<MessageAttachment>?, bool) parseAttachments(
|
||||
Map<String, dynamic> map,
|
||||
) {
|
||||
List<MessageAttachment>? attachments;
|
||||
final link = map['link'];
|
||||
final linkType = link is Map ? link['type'] as String? : null;
|
||||
if (linkType == 'FORWARD') {
|
||||
attachments = [ForwardedMessageAttachment.fromMap(map)];
|
||||
} else {
|
||||
final attaches = map['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
final isControl =
|
||||
attachments?.any((a) => a.type == AttachmentType.control) ?? false;
|
||||
return (attachments, isControl);
|
||||
}
|
||||
|
||||
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
|
||||
Map<String, dynamic>? payload;
|
||||
final payloadRaw = row['payload'];
|
||||
@@ -444,22 +470,11 @@ class CachedMessage {
|
||||
}
|
||||
|
||||
List<MessageAttachment>? attachments;
|
||||
bool isControl = false;
|
||||
if (payload != null) {
|
||||
final linkType = payload['link']?['type'] as String?;
|
||||
if (linkType == 'FORWARD') {
|
||||
attachments = [ForwardedMessageAttachment.fromMap(payload)];
|
||||
} else {
|
||||
final attaches = payload['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.map(
|
||||
(a) => MessageAttachment.fromMap(
|
||||
Map<String, dynamic>.from(a as Map),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
final parsed = parseAttachments(payload);
|
||||
attachments = parsed.$1;
|
||||
isControl = parsed.$2;
|
||||
}
|
||||
|
||||
return CachedMessage(
|
||||
@@ -480,8 +495,7 @@ class CachedMessage {
|
||||
status: row['status']?.toString(),
|
||||
payload: payload,
|
||||
attachments: attachments,
|
||||
isControl:
|
||||
attachments?.any((a) => a.type == AttachmentType.control) ?? false,
|
||||
isControl: isControl,
|
||||
deleted: row['deleted'] is int
|
||||
? row['deleted'] == 1
|
||||
: row['deleted']?.toString() == '1',
|
||||
@@ -503,7 +517,8 @@ class CachedMessage {
|
||||
|
||||
ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload);
|
||||
|
||||
List<FormatRange> get formatRanges => parseFormatElements(payload?['elements']);
|
||||
List<FormatRange> get formatRanges =>
|
||||
parseFormatElements(payload?['elements']);
|
||||
|
||||
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
|
||||
rows.map(CachedMessage.fromDbRow).toList();
|
||||
@@ -531,14 +546,8 @@ class CachedMessage {
|
||||
};
|
||||
|
||||
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
|
||||
List<MessageAttachment>? attachments;
|
||||
final attaches = msg['attaches'];
|
||||
if (attaches is List && attaches.isNotEmpty) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
final full = Map<String, dynamic>.from(msg);
|
||||
final parsed = parseAttachments(full);
|
||||
return CachedMessage(
|
||||
id: msg['id']?.toString() ?? '',
|
||||
accountId: accountId,
|
||||
@@ -547,8 +556,9 @@ class CachedMessage {
|
||||
text: msg['text'] as String?,
|
||||
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
|
||||
status: (msg['status'] as String?) ?? 'sent',
|
||||
payload: Map<String, dynamic>.from(msg),
|
||||
attachments: attachments,
|
||||
payload: full,
|
||||
attachments: parsed.$1,
|
||||
isControl: parsed.$2,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -558,11 +568,6 @@ class MessagesModule {
|
||||
|
||||
MessagesModule(this._api);
|
||||
|
||||
/// Загружает историю сообщений для указанного чата.
|
||||
///
|
||||
/// [fromTime] — опционально, время от которого грузить (миллисекунды).
|
||||
/// Если не указано, грузит самые свежие.
|
||||
/// [count] — количество сообщений.
|
||||
Future<List<CachedMessage>> fetchHistory(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
@@ -573,10 +578,7 @@ class MessagesModule {
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'from':
|
||||
fromTime ??
|
||||
(DateTime.now().millisecondsSinceEpoch +
|
||||
86400000), // +1 день для запаса
|
||||
'from': fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000),
|
||||
'forward': forward,
|
||||
'backward': backward ?? count,
|
||||
'getMessages': true,
|
||||
@@ -614,9 +616,7 @@ class MessagesModule {
|
||||
|
||||
if (toSave.isNotEmpty) {
|
||||
try {
|
||||
await AppDatabase.saveMessages(
|
||||
toSave.map((m) => m.toDbRow()).toList(),
|
||||
);
|
||||
await AppDatabase.saveMessages(toSave.map((m) => m.toDbRow()).toList());
|
||||
} catch (e) {
|
||||
logger.e('saveMessages error: $e');
|
||||
}
|
||||
@@ -625,11 +625,6 @@ class MessagesModule {
|
||||
return toSave;
|
||||
}
|
||||
|
||||
/// Поиск сообщений в чате по строке [query] (opcode 73).
|
||||
///
|
||||
/// Возвращает сырые записи результата вида
|
||||
/// `{'message': {...}, 'highlights': [...]}`, отсортированные сервером
|
||||
/// от новых к старым.
|
||||
Future<List<Map<String, dynamic>>> searchMessages(
|
||||
int chatId,
|
||||
String query, {
|
||||
@@ -691,7 +686,6 @@ class MessagesModule {
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Загружает сообщения из локальной базы данных.
|
||||
Future<List<CachedMessage>> getLocalHistory(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
@@ -715,30 +709,8 @@ class MessagesModule {
|
||||
final id = m['id']?.toString();
|
||||
if (id == null) return null;
|
||||
|
||||
final linkRaw = m['link'];
|
||||
String? linkType;
|
||||
if (linkRaw is Map) {
|
||||
linkType = linkRaw['type'] as String?;
|
||||
}
|
||||
|
||||
List<MessageAttachment>? attachments;
|
||||
bool isControl = false;
|
||||
if (linkType == 'FORWARD') {
|
||||
final fwdMap = Map<String, dynamic>.from(m.cast());
|
||||
attachments = [ForwardedMessageAttachment.fromMap(fwdMap)];
|
||||
} else {
|
||||
final attaches = m['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
// Detect CONTROL
|
||||
if (attachments.any((a) => a.type == AttachmentType.control)) {
|
||||
isControl = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
final full = Map<String, dynamic>.from(m.cast());
|
||||
final parsed = CachedMessage.parseAttachments(full);
|
||||
|
||||
return CachedMessage(
|
||||
id: id,
|
||||
@@ -748,9 +720,9 @@ class MessagesModule {
|
||||
text: m['text']?.toString(),
|
||||
time: _parseIntField(m['time']),
|
||||
status: m['status']?.toString(),
|
||||
payload: Map<String, dynamic>.from(m.cast()),
|
||||
attachments: attachments,
|
||||
isControl: isControl,
|
||||
payload: full,
|
||||
attachments: parsed.$1,
|
||||
isControl: parsed.$2,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -789,20 +761,18 @@ class MessagesModule {
|
||||
'notifySender': true,
|
||||
};
|
||||
}
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': message,
|
||||
'notify': notify,
|
||||
};
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
return _sendAndExtractMessageId(payload, 'Ошибка отправки');
|
||||
}
|
||||
|
||||
Future<String> _sendAndExtractMessageId(
|
||||
Map<String, dynamic> payload,
|
||||
String defaultError,
|
||||
) async {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) {
|
||||
final msg = (response.payload is Map)
|
||||
? (response.payload['localizedMessage'] ??
|
||||
response.payload['message'] ??
|
||||
'Ошибка отправки')
|
||||
: 'Ошибка отправки';
|
||||
throw Exception(msg.toString());
|
||||
_throwSendError(response.payload, defaultError);
|
||||
}
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
@@ -815,11 +785,46 @@ class MessagesModule {
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Пересылает сообщение [messageId] из чата [sourceChatId] в [targetChatId].
|
||||
///
|
||||
/// Пересылка — это отдельное сообщение без текста и вложений, со ссылкой
|
||||
/// `link.type = FORWARD`, указывающей на оригинал. Сервер сам подставит
|
||||
/// тело оригинала в ответе.
|
||||
Never _throwSendError(dynamic payload, String fallback) {
|
||||
final msg = (payload is Map)
|
||||
? (payload['localizedMessage'] ?? payload['message'] ?? fallback)
|
||||
: fallback;
|
||||
throw Exception(msg.toString());
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _sentMessageMap(Packet response) {
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<T> _sendWithNotReadyRetry<T>({
|
||||
required Map<String, dynamic> payload,
|
||||
required int maxAttempts,
|
||||
required Duration retryDelay,
|
||||
required T Function(Packet response) onResult,
|
||||
required T onExhausted,
|
||||
}) async {
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
return onResult(response);
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return onExhausted;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return onExhausted;
|
||||
}
|
||||
|
||||
Future<String> forwardMessage(
|
||||
int targetChatId,
|
||||
int sourceChatId,
|
||||
@@ -844,24 +849,7 @@ class MessagesModule {
|
||||
'notify': notify,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) {
|
||||
final msg = (response.payload is Map)
|
||||
? (response.payload['localizedMessage'] ??
|
||||
response.payload['message'] ??
|
||||
'Ошибка пересылки')
|
||||
: 'Ошибка пересылки';
|
||||
throw Exception(msg.toString());
|
||||
}
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msgMap = data['message'];
|
||||
if (msgMap is Map) {
|
||||
final id = msgMap['id'];
|
||||
if (id != null) return id.toString();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
return _sendAndExtractMessageId(payload, 'Ошибка пересылки');
|
||||
}
|
||||
|
||||
static CachedMessage buildForwardMessage({
|
||||
@@ -955,18 +943,13 @@ class MessagesModule {
|
||||
],
|
||||
'attaches': [],
|
||||
};
|
||||
final response = await _api.sendRequest(Opcode.msgSend, {
|
||||
return _api.sendRequestOk(Opcode.msgSend, {
|
||||
'chatId': chatId,
|
||||
'message': message,
|
||||
'notify': true,
|
||||
});
|
||||
return response.isOk;
|
||||
}
|
||||
|
||||
/// Загружает отложенные (запланированные) сообщения чата.
|
||||
///
|
||||
/// В отличие от обычной истории, отложенные сообщения не сохраняются
|
||||
/// в локальную БД — они живут только до момента отправки.
|
||||
Future<List<CachedMessage>> fetchDelayedMessages(
|
||||
int accountId,
|
||||
int chatId,
|
||||
@@ -1009,10 +992,6 @@ class MessagesModule {
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Редактирует текст (подпись) обычного сообщения.
|
||||
///
|
||||
/// Поле `attachments` не передаётся — сервер сохраняет существующие
|
||||
/// вложения.
|
||||
Future<bool> editMessage(
|
||||
int chatId,
|
||||
String messageId, {
|
||||
@@ -1031,13 +1010,9 @@ class MessagesModule {
|
||||
};
|
||||
if (sendAttachments) payload['attachments'] = const <dynamic>[];
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgEdit, payload);
|
||||
return response.isOk;
|
||||
return _api.sendRequestOk(Opcode.msgEdit, payload);
|
||||
}
|
||||
|
||||
/// Редактирует отложенное сообщение: меняет текст и/или время отправки.
|
||||
///
|
||||
/// Вложения сервер сохраняет сам — в payload они не передаются.
|
||||
Future<bool> editScheduledMessage(
|
||||
int chatId,
|
||||
String messageId, {
|
||||
@@ -1052,14 +1027,10 @@ class MessagesModule {
|
||||
'chatId': chatId,
|
||||
'elements': <dynamic>[],
|
||||
'text': text,
|
||||
'delayedAttributes': {
|
||||
'timeToFire': timeToFire,
|
||||
'notifySender': true,
|
||||
},
|
||||
'delayedAttributes': {'timeToFire': timeToFire, 'notifySender': true},
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgEdit, payload);
|
||||
return response.isOk;
|
||||
return _api.sendRequestOk(Opcode.msgEdit, payload);
|
||||
}
|
||||
|
||||
Future<bool> deleteMessages(
|
||||
@@ -1081,8 +1052,7 @@ class MessagesModule {
|
||||
'itemType': itemType,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgDelete, payload);
|
||||
return response.isOk;
|
||||
return _api.sendRequestOk(Opcode.msgDelete, payload);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> sendButtonCallback({
|
||||
@@ -1168,7 +1138,6 @@ class MessagesModule {
|
||||
int? scheduledTime,
|
||||
int maxAttempts = 20,
|
||||
Duration retryDelay = const Duration(seconds: 1),
|
||||
Duration initialDelay = const Duration(seconds: 3),
|
||||
}) async {
|
||||
final message = <String, dynamic>{
|
||||
'isLive': false,
|
||||
@@ -1188,29 +1157,15 @@ class MessagesModule {
|
||||
'notifySender': true,
|
||||
};
|
||||
}
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': message,
|
||||
'notify': notify,
|
||||
};
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
await Future.delayed(initialDelay);
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (response.isOk) return true;
|
||||
return false;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return false;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return _sendWithNotReadyRetry<bool>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: (response) => response.isOk,
|
||||
onExhausted: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> requestPhotoUploadUrl() async {
|
||||
@@ -1246,29 +1201,15 @@ class MessagesModule {
|
||||
}
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Запрашивает URL для загрузки видео (опкод 82).
|
||||
Future<VideoUploadInfo?> requestVideoUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.videoUpload, {
|
||||
'uploaderType': 0,
|
||||
@@ -1293,9 +1234,6 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправляет сообщение с видео по [token], полученному из
|
||||
/// [requestVideoUploadUrl]. Сервер может ответить `attachment.not.ready`,
|
||||
/// пока обрабатывает загруженное видео — в этом случае запрос повторяется.
|
||||
Future<Map<String, dynamic>?> sendVideoMessage(
|
||||
int chatId,
|
||||
String token, {
|
||||
@@ -1323,33 +1261,15 @@ class MessagesModule {
|
||||
}
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Запрашивает URL для загрузки голосового сообщения (опкод 82).
|
||||
///
|
||||
/// Тот же опкод, что и у видео, но `uploaderType: 1, type: 2`. В ответе
|
||||
/// `videoId` — это идентификатор аудио (`audioId`), а `token` уже выдан и
|
||||
/// используется в [sendAudioMessage] после загрузки байтов.
|
||||
Future<AudioUploadInfo?> requestAudioUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.videoUpload, {
|
||||
'uploaderType': 1,
|
||||
@@ -1374,13 +1294,6 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправляет голосовое сообщение по [token], полученному из
|
||||
/// [requestAudioUploadUrl], после загрузки Ogg/Opus-байтов на CDN.
|
||||
///
|
||||
/// [duration] — длительность в миллисекундах. [wave] — hex-строка амплитуд
|
||||
/// для дорожки; если пусто, отправляется плоская (нулевая) волна, которую
|
||||
/// сервер принимает. Сервер может ответить `attachment.not.ready`, пока
|
||||
/// обрабатывает загрузку — запрос повторяется.
|
||||
Future<Map<String, dynamic>?> sendAudioMessage(
|
||||
int chatId,
|
||||
String token, {
|
||||
@@ -1413,30 +1326,15 @@ class MessagesModule {
|
||||
}
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Запрашивает URL для загрузки видеосообщения-кружка (опкод 82,
|
||||
/// `uploaderType: 1, type: 1`). Ответ — `vu.oneme.ru/uploadVideo` + token.
|
||||
Future<VideoUploadInfo?> requestVideoNoteUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.videoUpload, {
|
||||
'uploaderType': 1,
|
||||
@@ -1461,13 +1359,6 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправляет видеосообщение-кружок (`videoType: 1`) по [token], полученному
|
||||
/// из [requestVideoNoteUploadUrl], после загрузки MP4-байтов на CDN.
|
||||
///
|
||||
/// [duration] — длительность в мс. [wave] — амплитуды аудиодорожки (бинарь,
|
||||
/// 80 байт; нули допустимы). [thumbhash] — компактный хеш превью (опционально,
|
||||
/// сервер всё равно отдаёт собственный `previewData`). Повторяет запрос на
|
||||
/// `attachment.not.ready`, пока CDN обрабатывает загрузку.
|
||||
Future<Map<String, dynamic>?> sendVideoNoteMessage(
|
||||
int chatId,
|
||||
String token, {
|
||||
@@ -1496,26 +1387,13 @@ class MessagesModule {
|
||||
};
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (!(e.errorKey?.contains('not.ready') ?? false)) {
|
||||
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
|
||||
rethrow;
|
||||
}
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: _sentMessageMap,
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> sendLocationMessage(
|
||||
@@ -1542,15 +1420,12 @@ class MessagesModule {
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
static const int _pollAnonymousFlag = 4;
|
||||
static const int _pollMultipleFlag = 1;
|
||||
|
||||
Future<Map<String, dynamic>?> sendPollMessage(
|
||||
int chatId,
|
||||
String title,
|
||||
@@ -1559,7 +1434,9 @@ class MessagesModule {
|
||||
bool anonymous = true,
|
||||
bool notify = true,
|
||||
}) async {
|
||||
final settings = (anonymous ? 4 : 0) | (multiple ? 1 : 0);
|
||||
final settings =
|
||||
(anonymous ? _pollAnonymousFlag : 0) |
|
||||
(multiple ? _pollMultipleFlag : 0);
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': {
|
||||
@@ -1579,13 +1456,7 @@ class MessagesModule {
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
void sendTyping(int chatId, String type) {
|
||||
@@ -1616,13 +1487,7 @@ class MessagesModule {
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||
@@ -1662,13 +1527,6 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Запрашивает у сервера ссылки на воспроизведение видео (opcode 83).
|
||||
///
|
||||
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
|
||||
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
|
||||
/// Возвращает все доступные progressive-MP4 качества (label → URL),
|
||||
/// отсортированные по убыванию. URL'ы — готовые подписанные ссылки на CDN,
|
||||
/// поддерживающие HTTP range, поэтому пригодны для стриминга.
|
||||
Future<Map<String, String>> getVideoSources({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
@@ -1713,7 +1571,6 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает один лучший progressive-MP4 (или HLS как запасной).
|
||||
Future<String?> getVideoUrl({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
@@ -1769,10 +1626,6 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
|
||||
///
|
||||
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
|
||||
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
|
||||
Future<String?> getFileUrl({
|
||||
required String messageId,
|
||||
required int chatId,
|
||||
@@ -1836,7 +1689,7 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
ChatsModule.applyContactUpdate(contactId);
|
||||
chats.applyContactUpdate(contactId);
|
||||
return fullName;
|
||||
}
|
||||
}
|
||||
@@ -1898,7 +1751,7 @@ class MessagesModule {
|
||||
ContactCache.putOptions(id, rawOpts.whereType<String>().toSet());
|
||||
}
|
||||
|
||||
ChatsModule.applyContactUpdate(id);
|
||||
chats.applyContactUpdate(id);
|
||||
resolvedAny = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../api.dart';
|
||||
import 'chats.dart';
|
||||
import 'messages.dart';
|
||||
@@ -42,11 +43,20 @@ class OutboxService {
|
||||
if (api.state != SessionState.online) break;
|
||||
final pending = CachedMessage.fromDbRow(row);
|
||||
final text = pending.text;
|
||||
if (text == null || text.isEmpty || pending.payload != null) continue;
|
||||
if (text == null || text.isEmpty) continue;
|
||||
|
||||
final payload = pending.payload;
|
||||
final replyToMessageId = _replyIdFromPayload(payload);
|
||||
final elements = _elementsFromPayload(payload);
|
||||
|
||||
try {
|
||||
final actualId =
|
||||
await messages.sendMessage(accountId, pending.chatId, text);
|
||||
final actualId = await messages.sendMessage(
|
||||
accountId,
|
||||
pending.chatId,
|
||||
text,
|
||||
replyToMessageId: replyToMessageId,
|
||||
elements: elements,
|
||||
);
|
||||
final sent = CachedMessage(
|
||||
id: actualId.isNotEmpty ? actualId : pending.id,
|
||||
accountId: accountId,
|
||||
@@ -55,28 +65,63 @@ class OutboxService {
|
||||
text: text,
|
||||
time: pending.time,
|
||||
status: 'sent',
|
||||
payload: payload,
|
||||
);
|
||||
await AppDatabase.saveMessages([sent.toDbRow()]);
|
||||
if (sent.id != pending.id) {
|
||||
await AppDatabase.deleteMessage(
|
||||
accountId, pending.chatId, pending.id);
|
||||
accountId,
|
||||
pending.chatId,
|
||||
pending.id,
|
||||
);
|
||||
}
|
||||
ChatsModule.emitMessageSent(pending.chatId, pending.id, sent);
|
||||
await ChatsModule.applyOutgoing(
|
||||
chats.emitMessageSent(pending.chatId, pending.id, sent);
|
||||
await chats.applyOutgoing(
|
||||
accountId,
|
||||
pending.chatId,
|
||||
messageId: sent.id,
|
||||
time: sent.time,
|
||||
text: text,
|
||||
status: 'sent',
|
||||
elements: elements.isEmpty ? null : elements,
|
||||
);
|
||||
} catch (_) {
|
||||
break;
|
||||
} catch (e) {
|
||||
logger.w('Outbox: отправка ${pending.id} не удалась: $e');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logger.e('Outbox flush: $e');
|
||||
} finally {
|
||||
_flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
int? _replyIdFromPayload(Map<String, dynamic>? payload) {
|
||||
if (payload == null) return null;
|
||||
final link = payload['link'];
|
||||
if (link is! Map) return null;
|
||||
if ((link['type'] as String?)?.toUpperCase() != 'REPLY') return null;
|
||||
final msg = link['message'];
|
||||
if (msg is Map) {
|
||||
final id = msg['id'];
|
||||
if (id is int) return id;
|
||||
if (id != null) return int.tryParse(id.toString());
|
||||
}
|
||||
final mid = link['messageId'];
|
||||
if (mid is int) return mid;
|
||||
if (mid != null) return int.tryParse(mid.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _elementsFromPayload(
|
||||
Map<String, dynamic>? payload,
|
||||
) {
|
||||
final raw = payload?['elements'];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../models/poll.dart';
|
||||
|
||||
class PollsModule extends ChangeNotifier {
|
||||
@@ -52,8 +53,8 @@ class PollsModule extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
} catch (_) {
|
||||
// тихо игнорируем — опрос просто не отобразится
|
||||
} catch (e) {
|
||||
logger.w('PollsModule.fetch: pollId=$pollId chatId=$chatId $e');
|
||||
} finally {
|
||||
_inFlight.remove(pollId);
|
||||
}
|
||||
@@ -84,7 +85,8 @@ class PollsModule extends ChangeNotifier {
|
||||
await fetch(chatId, messageId, pollId, force: true);
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logger.w('PollsModule.vote: pollId=$pollId chatId=$chatId $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ class SelfCheckService {
|
||||
|
||||
void checkNow() => unawaited(_check());
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void resume() {
|
||||
if (_api == null || _timer != null) return;
|
||||
_timer = Timer.periodic(interval, (_) => unawaited(_check()));
|
||||
checkNow();
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return;
|
||||
|
||||
@@ -47,12 +47,12 @@ class StickersModule {
|
||||
|
||||
Future<void> _loadFavorites() async {
|
||||
final favIds = <int>[];
|
||||
final favResp = await _api.sendRequest(Opcode.assetsUpdate, {
|
||||
final fav = await _api.sendRequestMap(Opcode.assetsUpdate, {
|
||||
'type': 'FAVORITE_STICKER',
|
||||
'sync': 0,
|
||||
});
|
||||
if (favResp.isOk && favResp.payload is Map) {
|
||||
final sections = favResp.payload['sections'];
|
||||
if (fav != null) {
|
||||
final sections = fav['sections'];
|
||||
if (sections is List) {
|
||||
for (final s in sections) {
|
||||
if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') {
|
||||
@@ -68,12 +68,12 @@ class StickersModule {
|
||||
final newSetIds = <int>[];
|
||||
int marker = 0;
|
||||
|
||||
final stickerResp = await _api.sendRequest(Opcode.assetsUpdate, {
|
||||
final stickerData = await _api.sendRequestMap(Opcode.assetsUpdate, {
|
||||
'type': 'STICKER',
|
||||
'sync': 0,
|
||||
});
|
||||
if (stickerResp.isOk && stickerResp.payload is Map) {
|
||||
final sections = stickerResp.payload['sections'];
|
||||
if (stickerData != null) {
|
||||
final sections = stickerData['sections'];
|
||||
if (sections is List) {
|
||||
for (final s in sections) {
|
||||
if (s is! Map) continue;
|
||||
@@ -91,16 +91,16 @@ class StickersModule {
|
||||
var guard = 0;
|
||||
while (marker != 0 && guard < 50) {
|
||||
guard++;
|
||||
final page = await _api.sendRequest(Opcode.assetsGet, {
|
||||
final page = await _api.sendRequestMap(Opcode.assetsGet, {
|
||||
'sectionId': 'NEW_STICKER_SETS',
|
||||
'from': marker,
|
||||
'count': 100,
|
||||
});
|
||||
if (!page.isOk || page.payload is! Map) break;
|
||||
if (page == null) break;
|
||||
final before = newSetIds.length;
|
||||
_appendIntList(newSetIds, page.payload['stickerSets']);
|
||||
_appendIntList(newSetIds, page['stickerSets']);
|
||||
if (newSetIds.length == before) break;
|
||||
final m = page.payload['marker'];
|
||||
final m = page['marker'];
|
||||
marker = m is int ? m : 0;
|
||||
}
|
||||
|
||||
@@ -112,47 +112,53 @@ class StickersModule {
|
||||
if (seen.add(id)) ordered.add(id);
|
||||
}
|
||||
_orderedSetIds = ordered;
|
||||
logger.i('Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних');
|
||||
logger.i(
|
||||
'Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних',
|
||||
);
|
||||
|
||||
await _ensureSetMetas(ordered);
|
||||
}
|
||||
|
||||
Future<void> _ensureSetMetas(List<int> ids) async {
|
||||
final missing = ids.where((id) => !_sets.containsKey(id)).toList();
|
||||
Future<void> _fetchAndCache<T>({
|
||||
required String type,
|
||||
required List<int> ids,
|
||||
required String listKey,
|
||||
required T Function(Map) fromMap,
|
||||
required Map<int, T> cache,
|
||||
}) async {
|
||||
final missing = ids.where((id) => !cache.containsKey(id)).toList();
|
||||
for (final batch in _chunk(missing, 100)) {
|
||||
final resp = await _api.sendRequest(Opcode.assetsGetByIds, {
|
||||
'type': 'STICKER_SET',
|
||||
final map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
|
||||
'type': type,
|
||||
'ids': batch,
|
||||
});
|
||||
if (!resp.isOk || resp.payload is! Map) continue;
|
||||
final list = resp.payload['stickerSets'];
|
||||
if (map == null) continue;
|
||||
final list = map[listKey];
|
||||
if (list is! List) continue;
|
||||
for (final e in list) {
|
||||
if (e is Map && e['id'] is int) {
|
||||
final set = StickerSet.fromMap(e);
|
||||
_sets[set.id] = set;
|
||||
cache[e['id'] as int] = fromMap(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureSetMetas(List<int> ids) => _fetchAndCache<StickerSet>(
|
||||
type: 'STICKER_SET',
|
||||
ids: ids,
|
||||
listKey: 'stickerSets',
|
||||
fromMap: StickerSet.fromMap,
|
||||
cache: _sets,
|
||||
);
|
||||
|
||||
Future<List<StickerItem>> ensureStickers(List<int> stickerIds) async {
|
||||
final missing = stickerIds.where((id) => !_stickers.containsKey(id)).toList();
|
||||
for (final batch in _chunk(missing, 100)) {
|
||||
final resp = await _api.sendRequest(Opcode.assetsGetByIds, {
|
||||
'type': 'STICKER',
|
||||
'ids': batch,
|
||||
});
|
||||
if (!resp.isOk || resp.payload is! Map) continue;
|
||||
final list = resp.payload['stickers'];
|
||||
if (list is! List) continue;
|
||||
for (final e in list) {
|
||||
if (e is Map && e['id'] is int) {
|
||||
final item = StickerItem.fromMap(e);
|
||||
_stickers[item.id] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _fetchAndCache<StickerItem>(
|
||||
type: 'STICKER',
|
||||
ids: stickerIds,
|
||||
listKey: 'stickers',
|
||||
fromMap: StickerItem.fromMap,
|
||||
cache: _stickers,
|
||||
);
|
||||
return stickerIds
|
||||
.map((id) => _stickers[id])
|
||||
.whereType<StickerItem>()
|
||||
@@ -167,9 +173,9 @@ class StickersModule {
|
||||
void cacheSet(StickerSet set) => _sets[set.id] = set;
|
||||
|
||||
Future<StickerSet?> resolveSetByLink(String link) async {
|
||||
final resp = await _api.sendRequest(Opcode.linkInfo, {'link': link});
|
||||
if (!resp.isOk || resp.payload is! Map) return null;
|
||||
final raw = resp.payload['stickerSet'];
|
||||
final map = await _api.sendRequestMap(Opcode.linkInfo, {'link': link});
|
||||
if (map == null) return null;
|
||||
final raw = map['stickerSet'];
|
||||
if (raw is! Map || raw['id'] is! int) return null;
|
||||
final set = StickerSet.fromMap(raw);
|
||||
_sets[set.id] = set;
|
||||
@@ -182,21 +188,21 @@ class StickersModule {
|
||||
}
|
||||
|
||||
Future<bool> favoriteSet(int setId) async {
|
||||
final resp = await _api.sendRequest(Opcode.assetsAdd, {
|
||||
final map = await _api.sendRequestMap(Opcode.assetsAdd, {
|
||||
'type': 'FAVORITE_STICKER_SET',
|
||||
'id': setId,
|
||||
});
|
||||
final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true;
|
||||
final ok = map != null && map['success'] == true;
|
||||
if (ok) _markFavorite(setId, true);
|
||||
return ok;
|
||||
}
|
||||
|
||||
Future<bool> unfavoriteSet(int setId) async {
|
||||
final resp = await _api.sendRequest(Opcode.assetsRemove, {
|
||||
final map = await _api.sendRequestMap(Opcode.assetsRemove, {
|
||||
'type': 'FAVORITE_STICKER_SET',
|
||||
'ids': [setId],
|
||||
});
|
||||
final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true;
|
||||
final ok = map != null && map['success'] == true;
|
||||
if (ok) _markFavorite(setId, false);
|
||||
return ok;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user