Import IKAR messenger client and server

This commit is contained in:
sevenhill
2026-10-07 22:06:07 +03:00
commit 69ea5091eb
1951 changed files with 424546 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../core/config/config.dart';
import '../core/config/countries.dart';
import '../core/protocol/opcode_map.dart';
import '../core/protocol/packet.dart';
import '../core/storage/token_storage.dart';
import '../core/storage/ikar_device_key.dart';
import '../core/transport/dispatcher.dart';
import '../core/utils/logger.dart';
import 'ikar_transport.dart';
import 'ikar_media_session.dart';
import 'login_gate.dart';
enum SessionState { disconnected, connecting, connected, online }
/// Client for ikar_server.
class Api {
Api({IkarTransport Function(Uri)? transportFactory})
: _transportFactory = transportFactory ?? IkarTransport.new;
final IkarTransport Function(Uri) _transportFactory;
final PacketDispatcher _dispatcher = PacketDispatcher();
final _states = StreamController<SessionState>.broadcast();
final _expired = StreamController<SessionExpiredException>.broadcast();
final _handshakes = StreamController<String>.broadcast();
final _errors = StreamController<String>.broadcast();
final LoginGate _loginGate = LoginGate();
SessionState _state = SessionState.disconnected;
IkarTransport? _transport;
IkarMediaSession? _media;
StreamSubscription<Map<String, dynamic>>? _events;
Timer? _heartbeat;
Timer? _reconnect;
Future<void> Function()? _onReconnect;
Future<bool>? _refreshing;
bool _autoReconnect = false;
bool _disposed = false;
int _epoch = 0;
int _generation = 0;
String? _deviceId;
final _challenges = <String, ({String payload, String scope})>{};
String? spoofScope;
Map<dynamic, dynamic>? get userAgent => {
'deviceType': 'ANDROID',
'deviceName': 'ИКАР',
'appVersion': '1.0.4',
};
int? get callsSeed => null;
String? get deviceId => _deviceId;
String? get callsDevice => 'ИКАР';
String? get callsOsVersion => 'Android';
String get architecture => 'arm64-v8a';
IkarMediaSession? get session => _media;
SessionState get state => _state;
int get sessionEpoch => _epoch;
bool get hasLoginAttempt => _loginGate.loginAttempted;
List<CountryName> get registrationCountries => allCountries;
Stream<SessionState> get stateStream => _states.stream;
Stream<SessionExpiredException> get sessionExpiredStream => _expired.stream;
Stream<String> get handshakeSuccessStream => _handshakes.stream;
Stream<String> get errorStream => _errors.stream;
Stream<Packet> get pushStream => _dispatcher.pushStream;
void _setState(SessionState state) {
_state = state;
if (!_states.isClosed) _states.add(state);
}
Future<void> connect({bool allowAutomaticLogin = true}) async {
if (_disposed || _state != SessionState.disconnected) return;
final generation = ++_generation;
_autoReconnect = true;
_loginGate.close();
_setState(SessionState.connecting);
try {
final endpoint = await ServerConfig.loadEndpoint();
final baseUri = Uri(
scheme: 'https',
host: endpoint.host,
port: endpoint.port,
);
final transport = _transportFactory(baseUri);
_transport = transport;
final ping = await transport.request(Opcode.ping, {});
if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен');
if (generation != _generation) {
await transport.close();
return;
}
_media = IkarMediaSession(baseUri);
_events = transport.events.listen(
(event) => _dispatcher.dispatch(
Packet(
cmd: CmdType.push,
opcode: (event['opcode'] as num).toInt(),
payload: event['payload'],
),
),
);
_epoch++;
_state = SessionState.online;
if (allowAutomaticLogin && _onReconnect != null) await _onReconnect!();
_loginGate.open();
_setState(SessionState.online);
_handshakes.add('ИКАР');
_heartbeat = Timer.periodic(
const Duration(seconds: 10),
(_) => unawaited(_checkConnection()),
);
} catch (error) {
if (generation != _generation) return;
logger.w('IKAR reconnect failed: $error');
await _clear();
_setState(SessionState.disconnected);
_scheduleReconnect();
}
}
Future<void> _checkConnection() async {
final transport = _transport;
if (transport == null || _state != SessionState.online) return;
try {
await sendRequest(Opcode.ping, {}, silent: true);
if (transport.token != null && !transport.eventsConnected)
await transport.connectEvents();
} catch (_) {
if (identical(_transport, transport)) {
await _clear();
_setState(SessionState.disconnected);
_scheduleReconnect();
}
}
}
void _scheduleReconnect() {
if (!_autoReconnect || _disposed) return;
_reconnect?.cancel();
_reconnect = Timer(const Duration(seconds: 5), () => unawaited(connect()));
}
Future<Packet> sendRequest(
int opcode,
Map<dynamic, dynamic> payload, {
bool silent = false,
void Function()? beforeSend,
}) async {
final initial = _transport;
if (initial == null && _state != SessionState.connecting)
throw StateError('Нет соединения (${Opcode.name(opcode)})');
if (opcode != Opcode.login)
await _loginGate.wait(ServerConfig.requestTimeout, Opcode.name(opcode));
final transport = _transport;
if (transport == null)
throw StateError('Нет соединения (${Opcode.name(opcode)})');
if (beforeSend != null && !identical(transport, initial))
throw StateError('Сессия изменилась до отправки');
beforeSend?.call();
final body = Map<dynamic, dynamic>.from(payload);
if (opcode == Opcode.authRequest) {
final identity = await IkarDeviceKey.identity(body['phone'].toString());
_deviceId = identity['installationId'];
body.addAll(identity);
}
if (opcode == Opcode.auth && _challenges.containsKey(body['token'])) {
final challenge = _challenges.remove(body['token'])!;
body['deviceSignature'] = await IkarDeviceKey.sign(
challenge.payload,
challenge.scope,
);
}
if (opcode == Opcode.login || opcode == Opcode.authConfirm)
transport.token = body['token'] as String? ?? transport.token;
if (opcode == Opcode.login) _loginGate.noteLoginSent();
var result = await _request(transport, opcode, body);
if (result['cmd'] == 3 &&
(result['payload'] as Map?)?['message'] == 'FAIL_LOGIN_TOKEN') {
if (await _refreshToken(transport))
result = await _request(transport, opcode, body);
}
final packet = Packet(
cmd: (result['cmd'] as num?)?.toInt() ?? 3,
opcode: opcode,
payload: result['payload'],
);
if (opcode == Opcode.login) _loginGate.noteLoginAnswer(ok: packet.isOk);
if (packet.isError) {
if (isSessionExpiredPayload(packet.payload)) {
final error = SessionExpiredException(
messageFromErrorPayload(packet.payload),
);
_expired.add(error);
throw error;
}
final message = messageFromErrorPayload(packet.payload);
if (!silent) _errors.add(message);
throw PacketError(
message,
errorKey:
result['errorKey'] as String? ??
(packet.payload as Map?)?['error'] as String?,
);
}
if (packet.payload is Map) {
final data = packet.payload as Map;
if (opcode == Opcode.authRequest && data['signaturePayload'] is String)
_challenges[data['token'] as String] = (
payload: data['signaturePayload'] as String,
scope: body['phone'].toString(),
);
await _saveIssuedTokens(data, transport);
}
if (opcode == Opcode.login) await transport.connectEvents();
return packet;
}
Future<Map<String, dynamic>> _request(
IkarTransport transport,
int opcode,
Map<dynamic, dynamic> body,
) async {
try {
return await transport.request(opcode, body);
} catch (error) {
if (error is SocketException ||
error is HttpException ||
error is HandshakeException ||
error is TimeoutException ||
error is http.ClientException) {
if (identical(_transport, transport) &&
_state != SessionState.disconnected) {
_setState(SessionState.disconnected);
await _clear();
_scheduleReconnect();
}
}
rethrow;
}
}
Future<void> _saveIssuedTokens(Map data, IkarTransport transport) async {
final attrs = data['tokenAttrs'];
Map? issued;
if (attrs is Map) issued = (attrs['LOGIN'] ?? attrs['REGISTER']) as Map?;
if (data['token'] is String && data['refreshToken'] is String)
issued = data;
if (issued == null) return;
final token = issued['token'] as String?;
final refresh = issued['refreshToken'] as String?;
final id =
(data['accountId'] as num?)?.toInt() ??
((data['profile'] as Map?)?['contact'] as Map?)?['id'] as int?;
if (token != null) transport.token = token;
if (id != null && token != null) {
await TokenStorage.saveToken(token, id);
if (refresh != null)
await TokenStorage.writeSecure('ikar_refresh_$id', refresh);
}
}
Future<bool> _refreshToken(IkarTransport transport) async {
if (_refreshing != null) return _refreshing!;
final future = () async {
final id = await TokenStorage.getActiveAccountId();
if (id == null) return false;
final refresh = await TokenStorage.readSecure('ikar_refresh_$id');
if (refresh == null) return false;
final session = await transport.refresh(refresh);
if (session == null) return false;
final access = session['accessToken'] as String?;
final nextRefresh = session['refreshToken'] as String?;
if (access == null || nextRefresh == null) return false;
transport.token = access;
await TokenStorage.saveToken(access, id);
await TokenStorage.writeSecure('ikar_refresh_$id', nextRefresh);
return true;
}();
_refreshing = future;
try {
return await future;
} finally {
if (identical(_refreshing, future)) _refreshing = null;
}
}
Future<Map<dynamic, dynamic>?> sendRequestMap(
int opcode,
Map<dynamic, dynamic> payload,
) async => (await sendRequest(opcode, payload)).payload as Map?;
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async =>
(await sendRequest(opcode, payload)).isOk;
Future<Packet> sendRequestOrThrow(
int opcode,
Map<dynamic, dynamic> payload,
) => sendRequest(opcode, payload);
void registerPushHandler(int opcode, void Function(Packet) handler) =>
_dispatcher.registerHandler(opcode, handler);
void unregisterPushHandler(int opcode) =>
_dispatcher.unregisterHandler(opcode);
void setReconnectCallback(Future<void> Function() callback) =>
_onReconnect = callback;
void wakeUp() {
if (_state == SessionState.disconnected)
unawaited(connect());
else
unawaited(_checkConnection());
}
void sendPing({required bool interactive}) => unawaited(_checkConnection());
Future<bool> reconnectToEndpoint({
String? host,
int? port,
bool trustMincifryCa = false,
}) async {
if (host != null && port != null) {
await ServerConfig.saveEndpoint(
host: host,
port: port,
trustMincifryCa: false,
);
}
await reconnectAndLogin();
return _state == SessionState.online;
}
Future<void> reconnectAndLogin() async {
await disconnect();
await connect();
}
Future<void> reconnectPreLogin() async {
await disconnect();
await connect(allowAutomaticLogin: false);
if (state != SessionState.online)
throw StateError('Не удалось подключиться к ИКАР');
}
Future<void> _clear() async {
_heartbeat?.cancel();
await _events?.cancel();
_events = null;
final transport = _transport;
_transport = null;
_media?.dispose();
_media = null;
await transport?.close();
_loginGate.close();
}
Future<void> disconnect() async {
_generation++;
_autoReconnect = false;
_reconnect?.cancel();
await _clear();
_setState(SessionState.disconnected);
}
Future<void> dispose() async {
_disposed = true;
await disconnect();
_dispatcher.dispose();
await _states.close();
await _expired.close();
await _handshakes.close();
await _errors.close();
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:kusoft/kusoft.dart' as kb;
import '../core/transport/ikar_network.dart';
/// Media reservations are short-lived URLs on ikar_server.
class IkarMediaSession {
IkarMediaSession(this.baseUri);
final Uri baseUri;
String userAgent() => 'IKAR/1.0.0 Android';
final _client = IkarNetwork.client();
Stream<kb.UploadEvent> _upload(
String url,
Stream<List<int>> bytes,
int size,
String filename,
String contentType,
) async* {
try {
final uri = Uri.parse(url);
if (uri.host != baseUri.host ||
uri.scheme != baseUri.scheme ||
uri.port != baseUri.port)
throw StateError('Адрес загрузки принадлежит другому серверу');
final request = await _client.postUrl(
uri.replace(
queryParameters: {...uri.queryParameters, 'filename': filename},
),
);
request.headers.set('Content-Type', contentType);
request.contentLength = size;
var sent = 0;
await for (final chunk in bytes) {
request.add(chunk);
sent += chunk.length;
yield kb.UploadEvent_Progress(
sent: BigInt.from(sent),
total: BigInt.from(size),
);
}
final response = await request.close().timeout(
const Duration(minutes: 5),
);
final body = await response.fold<List<int>>(
[],
(buffer, chunk) => buffer..addAll(chunk),
);
yield kb.UploadEvent_Done(
status: response.statusCode,
body: Uint8List.fromList(body),
);
} catch (error) {
yield kb.UploadEvent_Error(message: '$error');
}
}
Stream<kb.UploadEvent> uploadFilePath({
required String url,
required String path,
required String filename,
String? contentType,
String? connection,
}) async* {
final file = File(path);
yield* _upload(
url,
file.openRead(),
await file.length(),
filename,
contentType ?? 'application/octet-stream',
);
}
Stream<kb.UploadEvent> uploadPhoto({
required String url,
required Uint8List data,
required String filename,
}) => _upload(url, Stream.value(data), data.length, filename, 'image/jpeg');
Stream<kb.UploadEvent> uploadPhotoPath({
required String url,
required String path,
required String filename,
}) => uploadFilePath(
url: url,
path: path,
filename: filename,
contentType: 'image/jpeg',
);
Stream<kb.UploadEvent> uploadVideoPath({
required String url,
required String path,
required int chunkSize,
required int concurrency,
}) => uploadFilePath(
url: url,
path: path,
filename: 'video.mp4',
contentType: 'video/mp4',
);
void dispose() => _client.close(force: true);
}
+146
View File
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import '../core/transport/ikar_network.dart';
/// HTTPS requests and authenticated SignalR events from ikar_server.
class IkarTransport {
IkarTransport(this.baseUri, {http.Client? client})
: _client = client ?? IOClient(IkarNetwork.client()) {
if (baseUri.scheme != 'https' &&
!['localhost', '127.0.0.1', '::1'].contains(baseUri.host))
throw ArgumentError('ИКАР требует HTTPS');
}
final Uri baseUri;
final http.Client _client;
String? token;
WebSocket? _socket;
Timer? _keepAlive;
final _events = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get events => _events.stream;
bool get eventsConnected => _socket?.readyState == WebSocket.open;
Future<Map<String, dynamic>> request(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await _client
.post(
baseUri.resolve('/api/ikar/requests/$opcode'),
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
'X-Ikar-Client': 'ikar',
'X-Ikar-Client-Version': '1.0.4',
},
body: jsonEncode({'payload': _json(payload)}),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode == 401)
return {
'cmd': 3,
'opcode': opcode,
'payload': {'message': 'FAIL_LOGIN_TOKEN'},
};
if ([502, 503, 504].contains(response.statusCode)) {
throw HttpException('Сервер ИКАР недоступен', uri: response.request?.url);
}
final result = jsonDecode(response.body);
if (result is! Map)
throw const FormatException('Некорректный ответ сервера ИКАР');
return Map<String, dynamic>.from(result);
}
Future<Map<String, dynamic>?> refresh(String refreshToken) async {
final response = await _client
.post(
baseUri.resolve('/api/auth/refresh'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refreshToken': refreshToken}),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode != 200) return null;
return Map<String, dynamic>.from(jsonDecode(response.body) as Map);
}
Future<void> connectEvents() async {
await closeEvents();
if (token == null) return;
final uri = baseUri
.resolve('/hubs/messenger')
.replace(
scheme: baseUri.scheme == 'https' ? 'wss' : 'ws',
port: baseUri.port,
queryParameters: {'access_token': token!},
);
final socket = await IkarNetwork.connectWebSocket(
uri.toString(),
).timeout(const Duration(seconds: 15));
_socket = socket;
final handshake = Completer<void>();
var buffer = '';
socket.listen(
(frame) {
buffer += frame is String ? frame : utf8.decode(frame as List<int>);
while (buffer.contains('\u001e')) {
final index = buffer.indexOf('\u001e');
final record = buffer.substring(0, index);
buffer = buffer.substring(index + 1);
if (record.isEmpty) continue;
final message = jsonDecode(record) as Map;
if (!handshake.isCompleted && message['type'] == null) {
if (message['error'] != null) {
handshake.completeError(StateError('${message['error']}'));
} else {
handshake.complete();
}
}
if (message['type'] == 1 && message['target'] == 'QlyraPacket') {
final args = message['arguments'] as List;
if (args.isNotEmpty && !_events.isClosed)
_events.add(Map<String, dynamic>.from(args.first as Map));
}
if (message['type'] == 7) unawaited(socket.close());
}
},
onDone: () {
if (!handshake.isCompleted)
handshake.completeError(StateError('Соединение событий закрыто'));
if (identical(_socket, socket)) {
_socket = null;
_keepAlive?.cancel();
}
},
onError: (Object error) {
if (!handshake.isCompleted) handshake.completeError(error);
},
);
socket.add('${jsonEncode({'protocol': 'json', 'version': 1})}\u001e');
await handshake.future.timeout(const Duration(seconds: 15));
_keepAlive = Timer.periodic(const Duration(seconds: 10), (_) {
if (socket.readyState == WebSocket.open) socket.add('{"type":6}\u001e');
});
}
static Object? _json(Object? value) {
if (value is Map)
return value.map((key, item) => MapEntry(key.toString(), _json(item)));
if (value is Iterable) return value.map(_json).toList();
if (value is BigInt) return value.toInt();
return value;
}
Future<void> closeEvents() async {
_keepAlive?.cancel();
final socket = _socket;
_socket = null;
await socket?.close();
}
Future<void> close() async {
await closeEvents();
_client.close();
await _events.close();
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'dart:async';
class LoginGate {
Completer<void>? _gate;
bool _loginSent = false;
bool _loginAnswered = false;
bool _loginSucceeded = false;
bool get loginUnanswered => _loginSent && !_loginAnswered;
bool get loginAttempted => _loginSent;
bool get loginRejected => _loginSent && _loginAnswered && !_loginSucceeded;
void close() {
fail();
final gate = Completer<void>();
gate.future.ignore();
_gate = gate;
_loginSent = false;
_loginAnswered = false;
_loginSucceeded = false;
}
void open() {
if (_loginSent && !_loginSucceeded) return;
final gate = _gate;
if (gate != null && !gate.isCompleted) gate.complete();
}
void fail() {
final gate = _gate;
if (gate != null && !gate.isCompleted) {
gate.completeError(StateError('Нет соединения'));
}
}
void noteLoginSent() {
_loginSent = true;
_loginAnswered = false;
_loginSucceeded = false;
}
void noteLoginAnswer({required bool ok}) {
_loginAnswered = true;
_loginSucceeded = ok;
if (ok) open();
}
Future<void> wait(Duration timeout, String request) async {
final gate = _gate;
if (gate == null || gate.isCompleted) return;
await gate.future.timeout(
timeout,
onTimeout: () => throw TimeoutException('$request: вход не завершён'),
);
}
}
+253
View File
@@ -0,0 +1,253 @@
class FolderFilter {
static const int unread = 0;
static const int read = 1;
static const int channel = 2;
static const int chat = 3;
static const int dialog = 4;
static const int owner = 5;
static const int admin = 6;
static const int muted = 7;
static const int contact = 8;
static const int notContact = 9;
static const int bot = 10;
static const int notMuted = 11;
static const int markedUnread = 12;
static const int org = 13;
static const Set<int> chatTypes = {
contact,
notContact,
chat,
channel,
bot,
dialog,
org,
};
static const Set<int> roles = {owner, admin};
static const Set<int> showOnly = {
unread,
read,
muted,
notMuted,
markedUnread,
};
static const Map<String, int> _byName = {
'UNREAD': unread,
'READ': read,
'CHANNEL': channel,
'CHAT': chat,
'GROUP': chat,
'DIALOG': dialog,
'OWNER': owner,
'ADMIN': admin,
'MUTED': muted,
'CONTACT': contact,
'NOT_CONTACT': notContact,
'BOT': bot,
'NOT_MUTED': notMuted,
'MARKED_UNREAD': markedUnread,
'ORG': org,
};
static int? parse(dynamic raw) {
if (raw is int) return raw;
if (raw is String) return int.tryParse(raw) ?? _byName[raw];
return null;
}
}
class FolderOption {
static const int hideEmpty = 0;
static const int noDelete = 1;
static const int noTitleEdit = 2;
static const int noFiltersEdit = 3;
static const int chatSuggest = 4;
static const Map<String, int> _byName = {
'HIDE_EMPTY': hideEmpty,
'NO_DELETE': noDelete,
'NO_TITLE_EDIT': noTitleEdit,
'NO_FILTERS_EDIT': noFiltersEdit,
'CHAT_SUGGEST': chatSuggest,
};
static int? parse(dynamic raw) {
if (raw is int) return raw;
if (raw is String) return int.tryParse(raw) ?? _byName[raw];
return null;
}
}
class ChatFolder {
final String id;
final String title;
final String? emoji;
final List<int> include;
final List<int> filters;
final List<int> options;
final List<int> favorites;
final List<ChatFolderWidget> widgets;
final Map<String, dynamic>? filterSubjects;
final int updateTime;
final int? sourceId;
const ChatFolder({
required this.id,
required this.title,
this.emoji,
this.include = const [],
this.filters = const [],
this.options = const [],
this.favorites = const [],
this.widgets = const [],
this.filterSubjects,
this.updateTime = 0,
this.sourceId,
});
bool get hideEmpty => options.contains(FolderOption.hideEmpty);
bool get canDelete => !options.contains(FolderOption.noDelete);
bool get canEditTitle => !options.contains(FolderOption.noTitleEdit);
bool get canEditFilters => !options.contains(FolderOption.noFiltersEdit);
static List<int> _parseIds(dynamic raw) {
if (raw is! List) return <int>[];
return raw
.map((e) {
if (e is int) return e;
if (e is String) return int.tryParse(e);
return null;
})
.whereType<int>()
.toList();
}
static List<int> _parseCodes(dynamic raw, int? Function(dynamic) parse) {
if (raw is! List) return <int>[];
return raw.map(parse).whereType<int>().toList();
}
static Map<String, dynamic>? _parseMap(dynamic raw) {
if (raw is Map<String, dynamic>) return raw;
if (raw is Map) return Map<String, dynamic>.from(raw);
return null;
}
factory ChatFolder.fromJson(Map<String, dynamic> json) {
final options = _parseCodes(json['options'], FolderOption.parse);
if (json['hideEmpty'] == true &&
!options.contains(FolderOption.hideEmpty)) {
options.add(FolderOption.hideEmpty);
}
return ChatFolder(
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
emoji: json['emoji']?.toString(),
include: _parseIds(json['include']),
filters: _parseCodes(json['filters'], FolderFilter.parse),
options: options,
favorites: _parseIds(json['favorites']),
widgets:
(json['widgets'] as List<dynamic>?)
?.map(_parseMap)
.whereType<Map<String, dynamic>>()
.map(ChatFolderWidget.fromJson)
.toList() ??
const [],
filterSubjects: _parseMap(json['filterSubjects']),
updateTime: json['updateTime'] is int ? json['updateTime'] as int : 0,
sourceId: json['sourceId'] is int ? json['sourceId'] as int : null,
);
}
ChatFolder copyWith({
String? title,
String? emoji,
List<int>? include,
List<int>? filters,
List<int>? options,
List<int>? favorites,
int? updateTime,
}) => ChatFolder(
id: id,
title: title ?? this.title,
emoji: emoji ?? this.emoji,
include: include ?? this.include,
filters: filters ?? this.filters,
options: options ?? this.options,
favorites: favorites ?? this.favorites,
widgets: widgets,
filterSubjects: filterSubjects,
updateTime: updateTime ?? this.updateTime,
sourceId: sourceId,
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
if (emoji != null) 'emoji': emoji,
'include': include,
'filters': filters,
'options': options,
'favorites': favorites,
'widgets': widgets.map((w) => w.toJson()).toList(),
if (filterSubjects != null) 'filterSubjects': filterSubjects,
'updateTime': updateTime,
if (sourceId != null) 'sourceId': sourceId,
};
}
class ChatFolderWidget {
final int id;
final String name;
final String description;
final String? iconUrl;
final String? url;
final String? startParam;
final String? background;
final int? appId;
ChatFolderWidget({
required this.id,
required this.name,
required this.description,
this.iconUrl,
this.url,
this.startParam,
this.background,
this.appId,
});
factory ChatFolderWidget.fromJson(Map<String, dynamic> json) {
final rawId = json['id'];
return ChatFolderWidget(
id: rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '') ?? 0,
name: json['name']?.toString() ?? '',
description: json['description']?.toString() ?? '',
iconUrl: json['iconUrl']?.toString(),
url: json['url']?.toString(),
startParam: json['startParam']?.toString(),
background: json['background']?.toString(),
appId: json['appId'] is int
? json['appId'] as int
: int.tryParse(json['appId']?.toString() ?? ''),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'description': description,
if (iconUrl != null) 'iconUrl': iconUrl,
if (url != null) 'url': url,
if (startParam != null) 'startParam': startParam,
if (background != null) 'background': background,
if (appId != null) 'appId': appId,
};
}
+906
View File
@@ -0,0 +1,906 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import '../api.dart';
import '../../core/config/debug_test.dart';
import '../../core/config/qlyra_settings.dart';
import '../../core/protocol/chat_cache_fingerprint.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/media/media_playback.dart';
import '../../core/crypto/e2ee_service.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/profile_deletion_store.dart';
import '../../core/storage/spoofing_service.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../models/login_info.dart';
import 'banners.dart';
import 'chats.dart';
import 'complaints.dart';
import 'contacts.dart';
import 'folders.dart';
import 'messages.dart';
import 'webapp.dart';
import 'account/account_models.dart';
import 'account/privacy_module.dart';
import 'account/profile_module.dart';
import 'account/sessions_module.dart';
import 'account/two_factor_module.dart';
export 'account/account_models.dart';
// #***! номер к виду который ждёт сервер
String _normalizeAuthPhone(String phone) {
final digits = phone.replaceAll(RegExp(r'\D'), '');
return '+$digits';
}
// #***! маскируем номер, в логе ему делать нечего
String _maskPhone(String phone) {
if (phone.length <= 5) return '***';
return '${phone.substring(0, 3)}***${phone.substring(phone.length - 2)}';
}
// #***! аккаунт целиком, вход регистрация мультиаккаунт
class AccountModule {
final Api _api;
late final SessionsModule _sessions = SessionsModule(_api);
late final PrivacyModule _privacy = PrivacyModule(_api);
late final ProfileModule _profile = ProfileModule(_api);
late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile);
late final BannersModule banners = BannersModule(_api);
final _loginStatusController = StreamController<LoginStatus>.broadcast();
final _noticeController = StreamController<AccountNotice>.broadcast();
bool _loggedIn = false;
int? _loginEpoch;
// #***! тяжёлое в подмодулях, тут только оркестрация
AccountModule(this._api) {
_api.stateStream.listen((state) {
if (state != SessionState.online) _loggedIn = false;
});
}
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
Stream<AccountNotice> get noticeStream => _noticeController.stream;
/// `true`, только когда сервер считает сессию ONLINE — после успешного
/// login (opcode 19), а не просто после хэндшейка (opcode 6).
bool get isLoggedIn =>
_loggedIn &&
_loginEpoch == _api.sessionEpoch &&
_api.state == SessionState.online;
// #***! дальше обёртки над подмодулями, единая точка для юишки
Future<PrivacyConfig> getPrivacyConfig() => _privacy.getPrivacyConfig();
Future<List<BlockedContact>> getBlockedContacts() =>
_privacy.getBlockedContacts();
Future<PrivacyConfig> updatePrivacyConfig(Map<String, dynamic> settings) =>
_privacy.updatePrivacyConfig(settings);
Future<PrivacyConfig> setSafeMode(bool value) => _privacy.setSafeMode(value);
Future<PrivacyConfig> setChatsPushNotification(bool value) =>
_privacy.setChatsPushNotification(value);
Future<PrivacyConfig> setMessagePreview(bool value) =>
_privacy.setMessagePreview(value);
Future<PrivacyConfig> setNotificationSound(bool value) =>
_privacy.setNotificationSound(value);
Future<PrivacyConfig> setCallNotifications(bool value) =>
_privacy.setCallNotifications(value);
Future<PrivacyConfig> setNewContacts(bool value) =>
_privacy.setNewContacts(value);
Future<void> registerPushToken(String pushToken) =>
_privacy.registerPushToken(pushToken);
Future<void> unregisterPushToken(String pushToken) =>
_privacy.unregisterPushToken(pushToken);
Future<ProfileData> updateProfile(
String firstName,
String? lastName, {
String? description,
}) => _profile.updateProfile(firstName, lastName, description: description);
Future<ProfileData> updateProfileAvatar(
String photoToken, {
String avatarType = 'USER_AVATAR',
}) => _profile.updateProfileAvatar(photoToken, avatarType: avatarType);
Future<String> getAvatarUploadUrl() => _profile.getAvatarUploadUrl();
Future<ProfileData> removeProfilePhoto(int photoId) =>
_profile.removeProfilePhoto(photoId);
Future<String> create2faTrack() => _twoFactor.create2faTrack();
Future<void> set2faPassword(String trackId, String password) =>
_twoFactor.set2faPassword(trackId, password);
Future<void> set2faHint(String trackId, String hint) =>
_twoFactor.set2faHint(trackId, hint);
Future<int> verify2faEmail(String trackId, String email) =>
_twoFactor.verify2faEmail(trackId, email);
Future<String> verify2faCode(String trackId, String code) =>
_twoFactor.verify2faCode(trackId, code);
Future<ProfileData> confirm2fa({
required String trackId,
required String password,
String? hint,
bool withEmail = true,
}) => _twoFactor.confirm2fa(
trackId: trackId,
password: password,
hint: hint,
withEmail: withEmail,
);
Future<String> enter2faPanel() => _twoFactor.enter2faPanel();
Future<TwoFactorDetails> get2faDetails(String trackId) =>
_twoFactor.get2faDetails(trackId);
Future<TwoFactorDetails> get2faStatus() => _twoFactor.get2faStatus();
Future<void> check2faPassword(String trackId, String password) =>
_twoFactor.check2faPassword(trackId, password);
Future<ProfileData> update2faPassword({
required String trackId,
required String newPassword,
String? hint,
}) => _twoFactor.update2faPassword(
trackId: trackId,
newPassword: newPassword,
hint: hint,
);
Future<ProfileData> commit2faEmailChange(String trackId) =>
_twoFactor.commit2faEmailChange(trackId);
Future<ProfileData> remove2fa(String trackId) =>
_twoFactor.remove2fa(trackId);
// #***! запрос кода, первый и повторный отличаются типом
Future<RequestCodeResult> requestCode(
String phone, {
String language = 'ru',
}) => _requestCodeInternal(phone, AuthRequestType.startAuth, language);
Future<RequestCodeResult> resendCode(
String phone, {
String language = 'ru',
}) => _requestCodeInternal(phone, AuthRequestType.resend, language);
// #***! проверка кода, дальше вход регистрация или 2FA
Future<VerifyCodeResult> verifyCode(String code, String token) async {
_ensureOnline();
final payload = <dynamic, dynamic>{
'token': token,
'verifyCode': code,
'authTokenType': AuthRequestType.checkCode.value,
};
logger.i('Отправка OTP-кода (opcode=${Opcode.auth})');
final packet = await _api.sendRequest(Opcode.auth, payload);
final data = _requireMapPayload(packet, 'verifyCode');
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
final sessionToken = result.loginToken;
final verifiedProfile = _profileFromVerifyPayload(result.payload);
final accountId = result.accountId ?? verifiedProfile?.id;
// #***! токен сохраняем сразу и переносим спуф профиль
if (sessionToken != null && accountId != null) {
if (result.loginToken != null && verifiedProfile != null) {
await AppDatabase.saveProfile(verifiedProfile, isActive: true);
}
await TokenStorage.saveToken(sessionToken, accountId);
await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId);
}
return result;
}
ProfileData? _profileFromVerifyPayload(Map<dynamic, dynamic> payload) {
final profileMap = payload['profile'];
if (profileMap is! Map) return null;
return ProfileData.fromServerProfile(profileMap.cast<dynamic, dynamic>());
}
// #***! регистрация после кода
Future<int> completeRegistration({
required String token,
required String firstName,
String? lastName,
int? photoId,
}) async {
_ensureOnline();
final payload = <dynamic, dynamic>{
'token': token,
'tokenType': AuthRequestType.register.value,
'firstName': firstName,
};
if (lastName != null && lastName.isNotEmpty) {
payload['lastName'] = lastName;
}
if (photoId != null) {
payload['photoId'] = photoId;
payload['avatarType'] = 'PRESET_AVATAR';
}
logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})');
final packet = await _api.sendRequest(Opcode.authConfirm, payload);
final data = _requireMapPayload(packet, 'completeRegistration');
final loginToken = data['token'];
if (loginToken is! String || loginToken.trim().isEmpty) {
throw const FormatException(
'completeRegistration: отсутствует токен входа в ответе сервера',
);
}
if (data['tokenType'] != null && data['tokenType'] != 'LOGIN') {
throw const FormatException(
'completeRegistration: сервер не выдал токен типа LOGIN',
);
}
final profileMap = data['profile'];
if (profileMap is! Map) {
throw Exception('completeRegistration: отсутствует profile в ответе');
}
final contact = profileMap['contact'];
if (contact is! Map) {
throw Exception('completeRegistration: отсутствует profile.contact');
}
final accountId = contact['id'] as int?;
if (accountId == null) {
throw Exception('completeRegistration: отсутствует id аккаунта');
}
final profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await TokenStorage.saveToken(loginToken, accountId);
await AppDatabase.saveProfile(profile, isActive: true);
await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId);
logger.i('Регистрация завершена, accountId=$accountId');
return accountId;
}
// #***! основной вход, syncParams говорят серверу что у нас есть
Future<LoginResult> login({
int? accountId,
String? token,
LoginSyncParams? syncParams,
}) async {
_ensureOnline();
int? resolvedAccountId =
accountId ?? await TokenStorage.getActiveAccountId();
String? authToken = token;
if (authToken == null) {
if (resolvedAccountId == null) {
throw StateError('login: нет активного аккаунта');
}
authToken = await TokenStorage.readToken(resolvedAccountId);
if (authToken == null) {
throw StateError('login: нет токена для аккаунта $resolvedAccountId');
}
}
if (authToken.trim().isEmpty) {
throw StateError('login: пустой токен входа');
}
final requestPayload = buildLoginPayload(authToken, sync: syncParams);
final loginEpoch = _api.sessionEpoch;
_loggedIn = false;
_loginStatusController.add(LoginStatus.loading);
try {
final packet = await _api.sendRequest(Opcode.login, requestPayload);
final data = _requireMapPayload(packet, 'login');
final dataMap = data.cast<dynamic, dynamic>();
// #***! вошли по чужому токену, id узнаём из ответа
if (resolvedAccountId == null) {
resolvedAccountId = extractAccountId(dataMap);
if (resolvedAccountId == null) {
logger.e(
'login: accountId не найден в ответе; '
'${describeResponseShape(dataMap)}',
);
throw Exception('login: не удалось определить accountId из ответа');
}
logger.i('login: accountId=$resolvedAccountId определён из ответа');
await TokenStorage.saveToken(authToken, resolvedAccountId);
await TokenStorage.setActiveAccount(resolvedAccountId);
await SpoofingService.commitPendingSpoof(resolvedAccountId);
}
final result = await _processLoginResponse(dataMap, resolvedAccountId);
if (_api.sessionEpoch != loginEpoch ||
_api.state != SessionState.online) {
throw StateError('Session changed during login');
}
_loginEpoch = loginEpoch;
_loggedIn = true;
_loginStatusController.add(LoginStatus.success);
return result;
} catch (e) {
_loginStatusController.add(LoginStatus.error);
rethrow;
}
}
Future<List<SessionInfo>> getSessions() => _sessions.getSessions();
Future<void> terminateOtherSessions() => _sessions.terminateOtherSessions();
Future<void> authorizeWebQrLogin(String qrLink) =>
_sessions.authorizeWebQrLogin(qrLink);
// #***! второй аккаунт, рвём сессию чистим кэши готовим спуф
Future<void> beginAddAccount() async {
final existing = await AppDatabase.loadAllProfiles();
await SpoofingService.prepareNewAccountSpoof(
existing.map((p) => p.id).toList(growable: false),
);
try {
await _api.disconnect();
} catch (_) {}
await TokenStorage.clearActiveAccount();
ContactCache.clear();
TranscriptionCache.clear();
ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear();
chats.resetForAccountSwitch();
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
}
// #***! вход по чужому токену из дев меню
Future<LoginResult> loginWithToken(String token) async {
await TokenStorage.clearActiveAccount();
try {
await _api.disconnect();
} catch (_) {}
ContactCache.clear();
TranscriptionCache.clear();
ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear();
chats.resetForAccountSwitch();
await _api.connect();
if (_api.state != SessionState.online) {
throw StateError('loginWithToken: нет соединения с сервером');
}
logger.i('Вход по токену: сессия поднята со спуфом, выполняю login');
return login(token: token);
}
// #***! переключение аккаунта, реконнект с другим спуфом
Future<ProfileData> switchAccount(int accountId) async {
final profile = await AppDatabase.loadProfile(accountId);
final token = await TokenStorage.tryReadToken(accountId);
if (profile == null || token == null) {
logger.w(
'switchAccount: у аккаунта $accountId нет сохранённой сессии '
'(профиль: ${profile != null}, токен: ${token != null})',
);
throw AccountSessionLostException(accountId);
}
MediaPlayback.instance.closeAudioFile();
try {
await _api.disconnect();
} catch (_) {}
await AppDatabase.setActiveAccount(accountId);
await TokenStorage.setActiveAccount(accountId);
ContactCache.clear();
TranscriptionCache.clear();
ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear();
chats.resetForAccountSwitch();
await ContactsModule.primeCacheFromDb(accountId);
try {
await _api.connect();
} catch (e) {
logger.e(
'switchAccount: ошибка соединения при переключении на $accountId: $e',
);
throw StateError('switchAccount: не удалось подключиться к серверу');
}
if (_api.state != SessionState.online) {
logger.w(
'switchAccount: $accountId переключён без связи, сессия поднимется '
'при переподключении',
);
}
logger.i('Активный аккаунт переключён на $accountId');
return profile;
}
Future<List<ProfileData>> listAccounts() async {
return AppDatabase.loadAllProfiles();
}
Future<bool> hasStoredSession(int accountId) async =>
await TokenStorage.tryReadToken(accountId) != null;
// #***! удаляем локально, база токен спуф и заявка
Future<void> removeAccount(int accountId) async {
await E2eeService.instance.eraseAccount(accountId);
await AppDatabase.deleteAccount(accountId);
await TokenStorage.deleteAccount(accountId);
await SpoofingService.clearAccountSpoof(accountId);
await ProfileDeletionStore.clear(accountId);
logger.i('Аккаунт $accountId удалён локально');
}
Future<DateTime?> profileDeletionScheduledAt() async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return null;
return ProfileDeletionStore.scheduledAt(accountId);
}
// #***! заявка на удаление профиля, сервер вернёт дату
Future<DateTime?> setProfileDeletion(bool delete) async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.profileDelete, {
'delete': delete,
'type': 0,
});
final data = _requireMapPayload(packet, 'setProfileDeletion');
final raw = data['timestamp'];
final millis = raw is int ? raw : 0;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await ProfileDeletionStore.save(accountId, millis);
}
logger.i(
'Профиль: заявка на удаление '
'${delete ? 'создана' : 'отменена'} (timestamp=$millis)',
);
if (millis <= 0) return null;
return DateTime.fromMillisecondsSinceEpoch(millis);
}
// #***! выход, сначала сервер потом чистка
Future<void> logout() async {
MediaPlayback.instance.closeAudioFile();
final accountId = await TokenStorage.getActiveAccountId();
try {
await _logoutOnServer(accountId);
} on SessionExpiredException catch (e) {
logger.w('logout: сервер отклонил сессию: ${e.message}');
}
_loggedIn = false;
try {
await _api.disconnect();
} catch (_) {}
if (accountId != null) {
await removeAccount(accountId);
}
ContactCache.clear();
TranscriptionCache.clear();
ComplaintsModule.clear();
ContactsModule.clearBlockedCache();
banners.clear();
chats.resetForAccountSwitch();
}
Future<void> _logoutOnServer(int? accountId) async {
await _ensureLogoutSession(accountId);
await _api.sendRequestOrThrow(Opcode.logout, <dynamic, dynamic>{});
}
// #***! чтоб выйти нужна живая сессия, при чём логинимся заново
Future<void> _ensureLogoutSession(int? accountId) async {
if (_api.state == SessionState.disconnected) {
await _api.connect();
}
if (_api.state != SessionState.online) {
await _api.stateStream
.firstWhere((state) => state == SessionState.online)
.timeout(const Duration(seconds: 20));
}
if (_loggedIn) return;
if (accountId == null) return;
final token = await TokenStorage.readToken(accountId);
if (token == null || token.isEmpty) {
throw StateError('logout: нет токена для серверного выхода');
}
await _api.sendRequestOrThrow(
Opcode.login,
buildLoginPayload(token, interactive: false),
);
_loggedIn = true;
}
// #***! второй шаг входа при 2FA
Future<TwoFactorResult> checkPassword({
required String password,
required String trackId,
}) async {
_ensureOnline();
final payload = <dynamic, dynamic>{
'trackId': trackId,
'password': password,
};
logger.i('Проверка 2FA-пароля');
final packet = await _api.sendRequest(
Opcode.authLoginCheckPassword,
payload,
);
final data = _requireMapPayload(packet, 'checkPassword');
if (data['error'] != null) {
throw const WrongPasswordException();
}
final tokenAttrs = data['tokenAttrs'];
if (tokenAttrs is! Map) {
throw Exception('checkPassword: отсутствует tokenAttrs в ответе');
}
final loginEntry = tokenAttrs['LOGIN'];
if (loginEntry is! Map) {
throw Exception('checkPassword: отсутствует tokenAttrs.LOGIN в ответе');
}
final loginToken = loginEntry['token'] as String?;
if (loginToken == null || loginToken.isEmpty) {
throw Exception('checkPassword: отсутствует токен в ответе');
}
final accountId = extractAccountId(data);
if (accountId == null) {
throw Exception('checkPassword: отсутствует accountId в ответе');
}
final profileMap = data['profile'];
if (profileMap is Map) {
final profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true);
}
await TokenStorage.saveToken(loginToken, accountId);
await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId);
logger.i('2FA пройдена, получен login-токен');
return TwoFactorResult(loginToken: loginToken, accountId: accountId);
}
// #***! тело login, токен невидимка и маркеры синхры
Map<dynamic, dynamic> buildLoginPayload(
String token, {
LoginSyncParams? sync,
bool? interactive,
}) {
final payload = <dynamic, dynamic>{
'token': token,
'interactive': interactive ?? !QlyraSettings.ghostMode.value,
// #***! exp это экспериментальные фичи которые просим
'exp': {
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
},
};
final callsSeed = _api.callsSeed;
final deviceId = _api.deviceId;
// #***! без отпечатка сборки сервер не отдаст кэш чатов
if (callsSeed != null && deviceId != null) {
payload['chatCacheFingerprint'] = ChatCacheFingerprint.compute(
callsSeed,
deviceId,
arch: _api.architecture,
);
}
if (sync != null) {
payload['presenceSync'] = sync.presenceSync;
payload['chatsSync'] = sync.chatsSync;
payload['contactsSync'] = sync.contactsSync;
payload['callsSync'] = sync.callsSync;
payload['draftsSync'] = sync.draftsSync;
payload['bannersSync'] = sync.bannersSync;
payload['lastLogin'] = sync.lastLogin;
if (sync.serverConfigSeen && sync.configHash != null) {
payload['configHash'] = sync.configHash;
}
// #***! нет маркеров, просим всё с нуля
} else {
payload['presenceSync'] = -1;
payload['chatsSync'] = -1;
}
return payload;
}
// #***! разбор login, профиль чаты контакты папки баннеры
Future<LoginResult> _processLoginResponse(
Map<dynamic, dynamic> data,
int accountId,
) async {
final serverTime =
(data['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final updatedToken = data['token'] as String?;
if (updatedToken != null) {
await TokenStorage.saveToken(updatedToken, accountId);
}
ProfileData profile;
final profileMap = data['profile'];
// #***! берсерк ломает профиль специально чтоб проверить восстановление
if (!DebugTest.berserk &&
profileMap is Map &&
profileMap['contact'] is Map) {
profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
} else {
profile = await _resurrectProfile(accountId);
}
await AppDatabase.saveProfile(profile, isActive: true);
await AppDatabase.setActiveAccount(profile.id);
await _saveSyncState(data, serverTime, profile.id);
await ContactsModule.syncFromLoginPayload(data, profile.id);
await chats.syncFromLoginPayload(data, profile.id, profile.id);
// #***! остальные страницы чатов в фоне, вход не ждёт
unawaited(chats.paginateChats(_api, profile.id, profile.id, data));
// #***! каждый блок в try, упавший кусок не должен ронять вход
try {
await ContactsModule.syncFromServer(_api, profile.id);
} catch (e) {
logger.w('Контакты: $e');
}
final config = data['config'];
if (config is Map) {
await FoldersModule.applyFromLoginConfig(
profile.id,
config.cast<dynamic, dynamic>(),
);
await chats.applyFavorites(profile.id);
final userConfig = config['user'];
if (userConfig is Map) {
await AppDatabase.savePrivacyConfig(profile.id, jsonEncode(userConfig));
}
}
try {
await FoldersModule.syncFromServer(_api, profile.id);
} catch (e) {
logger.w('Папки чатов: $e');
}
await chats.applyFavorites(profile.id);
try {
await banners.initFromLogin(profile.id, data);
} catch (e) {
logger.w('Баннеры: $e');
}
try {
await _saveLoginInfo(data, profile.id);
} catch (e) {
logger.w('Info: $e');
}
return LoginResult(
profile: profile,
updatedToken: updatedToken,
serverTime: serverTime,
raw: data,
);
}
// #***! профиля нет нигде, тянем через контакт иначе заглушка
Future<ProfileData> _resurrectProfile(int accountId) async {
if (DebugTest.berserk) {
await AppDatabase.deleteAccount(accountId);
logger.w(
'login: [BERSERK] профиль удалён из БД, форсирую регенерацию (id=$accountId)',
);
} else {
final cached = await AppDatabase.loadProfile(accountId);
if (cached != null) return cached;
}
_noticeController.add(AccountNotice.resurrectingProfile);
try {
final fetched = await ContactsModule.fetchSelfProfile(_api, accountId);
if (fetched != null) {
logger.i(
'login: профиль восстановлен через CONTACT_INFO (id=$accountId)',
);
return fetched;
}
} catch (e) {
logger.w(
'login: восстановление профиля через CONTACT_INFO не удалось: $e',
);
}
logger.w('login: профиль недоступен, использую заглушку (id=$accountId)');
return ProfileData.stub(accountId);
}
// #***! маркеры синхры, считаем что знаем всё до serverTime
Future<void> _saveSyncState(
Map<dynamic, dynamic> data,
int serverTime,
int accountId,
) async {
final ts = serverTime.toString();
Future<void> set(String key, String value) =>
AppDatabase.setSyncValue(accountId, key, value);
await set(SyncKey.serverTime, ts);
await set(SyncKey.lastLogin, ts);
await set(SyncKey.chatsSync, ts);
await set(SyncKey.contactsSync, ts);
await set(SyncKey.callsSync, ts);
await set(SyncKey.draftsSync, ts);
await set(SyncKey.bannersSync, ts);
await set(SyncKey.presenceSync, '-1');
final config = data['config'];
if (config is Map) {
final hash = config['hash'] as String?;
if (hash != null) await set(SyncKey.configHash, hash);
}
}
// #***! сводка login для отладочного экрана
Future<void> _saveLoginInfo(Map<dynamic, dynamic> data, int accountId) async {
final config = data['config'] as Map?;
final serverConfig = config?['server'] as Map?;
if (serverConfig != null) {
await AppDatabase.setSyncValue(accountId, SyncKey.serverConfigSeen, '1');
await AppDatabase.setSyncValue(
accountId,
SyncKey.profileInviteLink,
serverConfig['invite-link']?.toString().trim() ?? '',
);
await _persistEntryBannerApps(accountId, serverConfig);
}
final info = LoginInfo.fromPayload(data);
await AppDatabase.saveLoginInfo(accountId, jsonEncode(info));
}
Future<void> _persistEntryBannerApps(int accountId, Map serverConfig) async {
final resolved = EntryBannerApps.appIdsFrom(serverConfig);
for (final entry in resolved.entries) {
await AppDatabase.setSyncValue(
accountId,
entry.key,
entry.value.toString(),
);
}
}
// #***! общий запрос кода
Future<RequestCodeResult> _requestCodeInternal(
String phone,
AuthRequestType type,
String language,
) async {
_ensureOnline();
final normalizedPhone = _normalizeAuthPhone(phone);
final payload = <dynamic, dynamic>{
'phone': normalizedPhone,
'type': type.value,
'language': language,
};
final callsSeed = _api.callsSeed;
final deviceId = _api.deviceId;
if (callsSeed != null && deviceId != null) {
payload['mode'] = ChatCacheFingerprint.compute(
callsSeed,
deviceId,
arch: _api.architecture,
);
}
logger.i(
'Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}',
);
final packet = await _api.sendRequest(Opcode.authRequest, payload);
final data = _requireMapPayload(packet, 'requestCode');
final token = data['token'];
if (token is! String || token.isEmpty) {
throw Exception('requestCode: отсутствует token в ответе сервера');
}
logger.i('OTP-код запрошен, получен временный токен');
return RequestCodeResult(
token: token,
deviceKey: data['authMethod'] == 'DEVICE_KEY',
);
}
// #***! дальше три помощника
void _ensureOnline() {
if (_api.state != SessionState.online) {
throw StateError(
'AccountModule: сессия не онлайн (текущее состояние: ${_api.state.name})',
);
}
}
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;
}
void _checkPacketError(Packet packet, String method) {
throwIfPacketError(packet);
}
}
@@ -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,528 @@
import 'dart:convert';
import '../../../core/storage/app_database.dart';
// #***! модели аккаунта, авторизация приватность сессии
int? _coerceAccountId(dynamic value) {
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) return int.tryParse(value.trim());
return null;
}
// #***! id аккаунта лежит в разных местах, ищем во всех
int? extractAccountId(dynamic response) {
if (response is! Map) return null;
final profile = response['profile'];
if (profile is Map) {
final contact = profile['contact'];
if (contact is Map) {
final cid =
_coerceAccountId(contact['id']) ??
_coerceAccountId(contact['contactId']) ??
_coerceAccountId(contact['accountId']);
if (cid != null) return cid;
}
final pid =
_coerceAccountId(profile['id']) ??
_coerceAccountId(profile['accountId']);
if (pid != null) return pid;
}
final contact = response['contact'];
if (contact is Map) {
final cid =
_coerceAccountId(contact['id']) ??
_coerceAccountId(contact['contactId']);
if (cid != null) return cid;
}
final account = response['account'];
if (account is Map) {
final aid =
_coerceAccountId(account['id']) ??
_coerceAccountId(account['accountId']);
if (aid != null) return aid;
}
return _coerceAccountId(response['accountId']) ??
_coerceAccountId(response['account_id']);
}
// #***! описание формы ответа для лога когда id не нашли
String describeResponseShape(dynamic response) {
if (response is! Map) return 'не-Map (${response.runtimeType})';
final sb = StringBuffer('keys=${response.keys.toList()}');
final profile = response['profile'];
if (profile is Map) {
sb.write(' profile.keys=${profile.keys.toList()}');
final contact = profile['contact'];
if (contact is Map) {
sb.write(' contact.keys=${contact.keys.toList()}');
} else {
sb.write(' profile.contact=${contact.runtimeType}');
}
} else {
sb.write(' profile=${profile.runtimeType}');
}
return sb.toString();
}
// #***! приватность одним объектом, ключи как у сервера капсом
class PrivacyConfig {
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,
);
}
}
// #***! состояние 2FA
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 }
enum AccountNotice { resurrectingProfile }
// #***! протухший токен FCM, надо перевыпустить
class WrongDeviceTokenException implements Exception {
const WrongDeviceTokenException();
@override
String toString() => 'WrongDeviceTokenException';
}
class AccountSessionLostException implements Exception {
final int accountId;
const AccountSessionLostException(this.accountId);
@override
String toString() => 'AccountSessionLostException($accountId)';
}
class WrongPasswordException implements Exception {
const WrongPasswordException();
@override
String toString() => 'WrongPasswordException';
}
// #***! временный токен сценария авторизации
class RequestCodeResult {
final String token;
final bool deviceKey;
const RequestCodeResult({required this.token, this.deviceKey = false});
}
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});
// #***! токены вложенно в tokenAttrs
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;
}
// #***! аккаунт с 2FA, дальше пароль
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 => extractAccountId(payload);
String? _nestedToken(String key) {
final attrs = payload['tokenAttrs'];
if (attrs is! Map) return null;
final entry = attrs[key];
if (entry is! Map) return null;
final token = entry['token'];
return token is String && token.trim().isNotEmpty ? token : null;
}
}
class TwoFactorResult {
final String loginToken;
final int accountId;
const TwoFactorResult({required this.loginToken, required this.accountId});
}
// #***! маркеры синхры для login, докуда мы уже знаем
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;
final bool serverConfigSeen;
static Future<void> forgetServerConfig(int accountId) =>
AppDatabase.setSyncValue(accountId, SyncKey.serverConfigSeen, '');
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,
this.serverConfigSeen = false,
});
// #***! нет lastLogin значит первый вход
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],
serverConfigSeen: values[SyncKey.serverConfigSeen] == '1',
);
}
}
// #***! активная сессия на другом устройстве
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'] ?? '',
);
}
// #***! сервер не всегда даёт id, ключ собираем из всех полей
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,119 @@
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';
// #***! приватность из базы, приходит в login и живёт локально
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();
}
// #***! обновление одним config, ответ в базу
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> setSafeMode(bool value) => updatePrivacyConfig(
value
? {
'INCOMING_CALL': 'CONTACTS',
'SEARCH_BY_PHONE': 'CONTACTS',
'SAFE_MODE_NO_PIN': true,
'CONTENT_LEVEL_ACCESS': true,
'CHATS_INVITE': 'CONTACTS',
'SAFE_MODE': true,
}
: {'SAFE_MODE_NO_PIN': false, 'SAFE_MODE': false},
);
// #***! дальше по методу на каждый переключатель
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});
// #***! регистрация push токена, ловим протухший FCM
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 _) async {
if (api.state != SessionState.online) return;
final packet = await api.sendRequest(Opcode.logout, <dynamic, dynamic>{});
throwIfPacketError(packet);
}
}
@@ -0,0 +1,117 @@
import 'dart:async';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart';
import '../contacts.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.fromServerProfile(
profile.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(newProfile, isActive: true);
return newProfile;
}
Future<ProfileData> updateProfile(
String firstName,
String? lastName, {
String? description,
}) async {
ensureOnline();
final payload = <dynamic, dynamic>{'firstName': firstName};
if (lastName != null) payload['lastName'] = lastName;
if (description != null) payload['description'] = description;
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,
});
final profile = await _applyProfileResponse(packet);
ContactsModule.invalidatePhotos(profile.id);
return profile;
}
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,
});
final profile = await _applyProfileResponse(packet);
ContactsModule.invalidatePhotos(profile.id);
return profile;
}
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.fromServerProfile(profile.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,53 @@
import '../../../core/protocol/opcode_map.dart';
import '../../../core/storage/token_storage.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');
final data = packet.payload;
final token = data is Map ? data['token'] : null;
if (token is String && token.isNotEmpty) {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
throw StateError(
'terminateOtherSessions: нет активного аккаунта для нового токена',
);
}
await TokenStorage.saveToken(token, accountId);
}
}
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 const WrongPasswordException();
}
}
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',
);
}
}
+194
View File
@@ -0,0 +1,194 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/emoji_keyword_index.dart';
import '../../core/utils/logger.dart';
import '../../models/animoji.dart';
class AnimojiModule {
final Api _api;
AnimojiModule(this._api);
static const List<String> fallbackReactions = [
'👍',
'❤️',
'🔥',
'🤣',
'😭',
'😍',
];
static const String _recentsKey = 'qlyra_recent_animoji';
static const int _maxRecents = 24;
final Map<int, Animoji> _byId = {};
final Map<String, Animoji> _byEmoji = {};
List<int> _orderedIds = [];
List<int> _recentIds = [];
bool _recentsLoaded = false;
Future<void>? _loading;
bool get isLoaded => _orderedIds.isNotEmpty;
List<Animoji> get animojis =>
_orderedIds.map((id) => _byId[id]).whereType<Animoji>().toList();
List<Animoji> get recentAnimojis =>
_recentIds.map((id) => _byId[id]).whereType<Animoji>().toList();
List<String> get emojis => animojis.map((a) => a.emoji).toList();
Future<void> ensureRecentsLoaded() async {
if (_recentsLoaded) return;
_recentsLoaded = true;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_recentsKey) ?? const [];
_recentIds = raw.map(int.tryParse).whereType<int>().toList();
} catch (_) {}
}
Future<void> noteUsed(Animoji animoji) async {
await ensureRecentsLoaded();
_remember(animoji);
_recentIds.remove(animoji.id);
_recentIds.insert(0, animoji.id);
if (_recentIds.length > _maxRecents) {
_recentIds = _recentIds.sublist(0, _maxRecents);
}
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_recentsKey,
_recentIds.map((e) => e.toString()).toList(),
);
} catch (_) {}
}
List<Animoji> get quickAnimojis {
final list = animojis;
return list.length <= 6 ? list : list.sublist(0, 6);
}
Animoji? cached(int id) => _byId[id];
Animoji? findByEmoji(String emoji) =>
_byEmoji[EmojiKeywordIndex.normalize(emoji)];
Future<Animoji?> fetchById(int id) async {
final known = _byId[id];
if (known != null) return known;
final map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
'type': 'ANIMOJI',
'ids': [id],
});
final list = map?['animojis'];
if (list is! List) return null;
for (final e in list) {
if (e is! Map) continue;
final animoji = Animoji.fromMap(e);
if (animoji != null) _remember(animoji);
}
return _byId[id];
}
Future<void> ensureLoaded() {
return _loading ??= _load().catchError((Object e) {
_loading = null;
throw e;
});
}
Future<void> _load() async {
final setIds = <int>[];
final fallbackIds = <int>[];
final sync = await _api.sendRequestMap(Opcode.assetsUpdate, {
'type': 'ANIMOJI_SET',
'sync': 0,
});
if (sync != null) {
final sections = sync['sections'];
if (sections is List) {
for (final s in sections) {
if (s is Map) _appendIntList(setIds, s['animojiSetIds']);
}
}
final updates = sync['animojiUpdates'];
if (updates is Map) {
for (final key in updates.keys) {
final id = key is int ? key : int.tryParse(key.toString());
if (id != null) fallbackIds.add(id);
}
}
}
final orderedIds = <int>[];
if (setIds.isNotEmpty) {
final setMap = await _api.sendRequestMap(Opcode.assetsGetByIds, {
'type': 'ANIMOJI_SET',
'ids': setIds,
});
if (setMap != null) {
final sets = setMap['animojiSets'];
if (sets is List) {
for (final set in sets) {
if (set is! Map) continue;
_appendIntList(orderedIds, set['animojis']);
_appendIntList(orderedIds, set['animojiIds']);
}
}
}
}
final ids = _dedup(orderedIds.isNotEmpty ? orderedIds : fallbackIds);
if (ids.isEmpty) return;
for (final batch in _chunk(ids, 100)) {
final map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
'type': 'ANIMOJI',
'ids': batch,
});
if (map == null) continue;
final list = map['animojis'];
if (list is! List) continue;
for (final e in list) {
if (e is! Map) continue;
final animoji = Animoji.fromMap(e);
if (animoji != null) _remember(animoji);
}
}
_orderedIds = ids.where(_byId.containsKey).toList();
logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций');
}
void _remember(Animoji animoji) {
_byId[animoji.id] = animoji;
_byEmoji[EmojiKeywordIndex.normalize(animoji.emoji)] = animoji;
}
List<int> _dedup(List<int> ids) {
final seen = <int>{};
final result = <int>[];
for (final id in ids) {
if (seen.add(id)) result.add(id);
}
return result;
}
void _appendIntList(List<int> target, dynamic raw) {
if (raw is! List) return;
for (final e in raw) {
if (e is int) target.add(e);
}
}
Iterable<List<T>> _chunk<T>(List<T> list, int size) sync* {
for (var i = 0; i < list.length; i += size) {
yield list.sublist(i, i + size > list.length ? list.length : i + size);
}
}
}
+337
View File
@@ -0,0 +1,337 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../models/informer_banner.dart';
class BannersModule {
static const _snapshotKey = 'informer_banners';
static const _stateKey = 'informer_state';
static const _enabledKey = 'informer_enabled';
static const _serverFlag = 'informer-enabled';
static const int _resyncMask = 1;
static const int _defaultShowTime = 86400000;
final Api _api;
BannersModule(this._api) {
_api.pushStream
.where((p) => p.opcode == Opcode.notifBanners)
.listen(_handlePush);
}
final ValueNotifier<InformerBanner?> activeBanner = ValueNotifier(null);
int? _accountId;
bool _enabled = true;
int _updateTime = 0;
int _showTime = _defaultShowTime;
List<InformerBanner> _banners = const [];
final Map<String, BannerShowState> _showState = {};
String? _lastShownId;
String? _pinnedId;
Future<void>? _syncing;
bool get isEnabled => _enabled;
int get showTime => _showTime;
List<InformerBanner> get banners => List.unmodifiable(_banners);
BannerShowState stateOf(String bannerId) =>
_showState[bannerId] ?? const BannerShowState();
Future<void> initFromLogin(
int accountId,
Map<dynamic, dynamic> loginData,
) async {
await load(accountId);
final config = loginData['config'];
final serverConfig = config is Map ? config['server'] : null;
if (serverConfig is Map && serverConfig.containsKey(_serverFlag)) {
await _setEnabled(accountId, _parseFlag(serverConfig[_serverFlag]));
}
if (!_enabled) return;
var applied = false;
final inline = loginData['banners'];
if (inline is Map) {
applied = await applyPayload(accountId, inline.cast<dynamic, dynamic>());
}
if (applied && !_needsResync(loginData['updates'])) return;
await syncFromServer();
}
Future<void> load(int accountId) async {
_accountId = accountId;
_pinnedId = null;
await _restoreSnapshot(accountId);
await _restoreShowState(accountId);
final enabled = await AppDatabase.getSyncValue(accountId, _enabledKey);
_enabled = enabled != '0';
_recompute();
}
Future<void> syncFromServer() {
return _syncing ??= _sync().whenComplete(() => _syncing = null);
}
Future<bool> applyPayload(
int accountId,
Map<dynamic, dynamic> payload,
) async {
final raw = payload['banners'];
if (raw is! List) return false;
final updateTime = _int(payload['updateTime']);
if (_updateTime != 0 && updateTime != null && updateTime <= _updateTime) {
return true;
}
final parsed = <InformerBanner>[];
for (final entry in raw) {
if (entry is! Map) continue;
final banner = InformerBanner.fromMap(entry);
if (banner != null) parsed.add(banner);
}
_banners = parsed;
if (updateTime != null) _updateTime = updateTime;
final showTime = _int(payload['showTime']);
if (showTime != null && showTime > 0) _showTime = showTime;
final known = parsed.map((b) => b.id).toSet();
_showState.removeWhere((id, _) => !known.contains(id));
if (_pinnedId != null && !known.contains(_pinnedId)) _pinnedId = null;
if (_lastShownId != null && !known.contains(_lastShownId)) {
_lastShownId = null;
}
await _persistSnapshot(accountId);
await _persistShowState(accountId);
_recompute();
return true;
}
Future<void> markShown(InformerBanner banner) async {
if (_pinnedId == banner.id) return;
final now = DateTime.now().millisecondsSinceEpoch;
final current = stateOf(banner.id);
_showState[banner.id] = current.copyWith(
showCounter: current.showCounter + 1,
showAt: current.showAt ?? now,
);
_lastShownId = banner.id;
_pinnedId = banner.id;
await _persistShowState(_accountId);
}
Future<void> close(InformerBanner banner) async {
final now = DateTime.now().millisecondsSinceEpoch;
_showState[banner.id] = stateOf(banner.id).copyWith(closedAt: now);
if (_pinnedId == banner.id) _pinnedId = null;
await _persistShowState(_accountId);
_recompute();
}
Future<void> markClicked(InformerBanner banner) async {
if (!banner.closesOnClick) return;
await close(banner);
}
void refresh() {
_pinnedId = null;
_recompute();
}
void clear() {
_accountId = null;
_enabled = true;
_updateTime = 0;
_showTime = _defaultShowTime;
_banners = const [];
_showState.clear();
_lastShownId = null;
_pinnedId = null;
activeBanner.value = null;
}
Future<void> _sync() async {
final accountId = _accountId;
if (accountId == null || !_enabled) return;
try {
final packet = await _api.sendRequest(Opcode.bannersSync, {
'bannersSync': 0,
});
throwIfPacketError(packet);
final payload = packet.payload;
if (payload is Map) {
await applyPayload(accountId, payload.cast<dynamic, dynamic>());
}
} catch (e) {
logger.w('Баннеры: синхронизация не удалась: $e');
}
}
void _handlePush(Packet packet) {
final accountId = _accountId;
if (accountId == null || !_enabled) return;
final payload = packet.payload;
if (payload is Map && payload['banners'] is List) {
unawaited(applyPayload(accountId, payload.cast<dynamic, dynamic>()));
return;
}
unawaited(syncFromServer());
}
void _recompute() {
if (!_enabled) {
activeBanner.value = null;
return;
}
final pinned = _pinnedId;
if (pinned != null) {
for (final banner in _banners) {
if (banner.id == pinned) {
activeBanner.value = banner;
return;
}
}
_pinnedId = null;
}
final now = DateTime.now().millisecondsSinceEpoch;
var candidates = _banners.where((b) => _canShow(b, now)).toList();
if (candidates.length > 1 && _lastShownId != null) {
final others = candidates.where((b) => b.id != _lastShownId).toList();
if (others.isNotEmpty) candidates = others;
}
if (candidates.isEmpty) {
activeBanner.value = null;
return;
}
activeBanner.value = candidates.reduce(
(a, b) => b.priority > a.priority ? b : a,
);
}
bool _canShow(InformerBanner banner, int now) {
final state = stateOf(banner.id);
if (state.showCounter > 0 && state.showCounter >= banner.repeat) {
return false;
}
final showAt = state.showAt;
if (showAt != null && now - showAt > _showTime) return false;
final closedAt = state.closedAt;
if (closedAt != null) {
if (banner.rerun <= 0) return false;
if (now - closedAt <= banner.rerun) return false;
}
return true;
}
bool _needsResync(dynamic updates) {
final mask = _int(updates);
return mask != null && mask & _resyncMask != 0;
}
Future<void> _setEnabled(int accountId, bool value) async {
_enabled = value;
await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0');
if (!value) activeBanner.value = null;
}
Future<void> _restoreSnapshot(int accountId) async {
_banners = const [];
_updateTime = 0;
_showTime = _defaultShowTime;
final raw = await AppDatabase.getSyncValue(accountId, _snapshotKey);
if (raw == null || raw.isEmpty) return;
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
final list = map['banners'];
final parsed = <InformerBanner>[];
if (list is List) {
for (final entry in list) {
if (entry is! Map) continue;
final banner = InformerBanner.fromMap(entry);
if (banner != null) parsed.add(banner);
}
}
_banners = parsed;
_updateTime = _int(map['updateTime']) ?? 0;
final showTime = _int(map['showTime']);
if (showTime != null && showTime > 0) _showTime = showTime;
} catch (_) {}
}
Future<void> _restoreShowState(int accountId) async {
_showState.clear();
_lastShownId = null;
final raw = await AppDatabase.getSyncValue(accountId, _stateKey);
if (raw == null || raw.isEmpty) return;
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
_lastShownId = map['lastShowedBannerId']?.toString();
final entries = map['banners'];
if (entries is Map) {
entries.forEach((key, value) {
if (value is Map) {
_showState[key.toString()] = BannerShowState.fromJson(value);
}
});
}
} catch (_) {}
}
Future<void> _persistSnapshot(int accountId) async {
await AppDatabase.setSyncValue(
accountId,
_snapshotKey,
jsonEncode({
'updateTime': _updateTime,
'showTime': _showTime,
'banners': _banners.map((b) => b.toJson()).toList(),
}),
);
}
Future<void> _persistShowState(int? accountId) async {
if (accountId == null) return;
await AppDatabase.setSyncValue(
accountId,
_stateKey,
jsonEncode({
'lastShowedBannerId': _lastShownId,
'banners': {
for (final entry in _showState.entries)
entry.key: entry.value.toJson(),
},
}),
);
}
static bool _parseFlag(dynamic value) {
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final v = value.trim().toLowerCase();
return v == 'true' || v == '1' || v == 'yes' || v == 'on';
}
return true;
}
static int? _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
}
+444
View File
@@ -0,0 +1,444 @@
import 'dart:convert';
import 'contacts.dart';
import '../api.dart';
import '../../core/calls/call_link.dart';
import '../../core/calls/ws2_signaling.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/ids.dart';
import '../../core/utils/logger.dart';
enum CallStatus { missed, canceled, outgoing, incoming }
class OutgoingCallParams {
final String conversationId;
final String endpoint;
final int callsUserId;
final int peerExternalId;
final bool isVideo;
const OutgoingCallParams({
required this.conversationId,
required this.endpoint,
required this.callsUserId,
required this.peerExternalId,
required this.isVideo,
});
}
class CreatedCall {
final String conversationId;
final String joinToken;
final String? callName;
final int? chatId;
const CreatedCall({
required this.conversationId,
required this.joinToken,
this.callName,
this.chatId,
});
String get url => CallLink.url(joinToken);
}
class CallLinkPreview {
final String? conferenceId;
final String? callName;
final int participantsCount;
final bool isVideo;
const CallLinkPreview({
this.conferenceId,
this.callName,
this.participantsCount = 0,
this.isVideo = false,
});
}
class CallLogEntry {
final String id;
final int accountId;
final int peerId;
final String name;
final String? avatarUrl;
final CallStatus status;
final int time;
final int count;
final bool isGroup;
const CallLogEntry({
required this.id,
required this.accountId,
required this.peerId,
required this.name,
this.avatarUrl,
required this.status,
required this.time,
this.count = 1,
this.isGroup = false,
});
}
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);
_CallerEndpoint _parseCallerEndpoint(
Map payload,
List<String> keys, {
required String context,
}) {
for (final key in keys) {
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) continue;
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);
}
throw _CallerEndpointMissingException('$context: no endpoint');
}
Future<OutgoingCallParams> initiateCall(
int calleeId, {
bool isVideo = false,
}) async {
final conversationId = uuidV4();
final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, {
'conversationId': conversationId,
'calleeIds': [calleeId],
'internalParams': _internalParams(),
'isVideo': isVideo,
});
if (payload == null) {
throw Exception('initiateCall: bad response');
}
final parsed = _parseCallerEndpoint(payload, const [
'internalCallerParams',
], context: 'initiateCall');
return OutgoingCallParams(
conversationId: (payload['conversationId'] as String?) ?? conversationId,
endpoint: parsed.endpoint,
callsUserId: parsed.callsUserId,
peerExternalId: parsed.external ?? calleeId,
isVideo: isVideo,
);
}
Future<CreatedCall> createConference() async {
final conversationId = uuidV4();
logger.i('[call] VIDEO_CHAT_START conv=$conversationId');
final payload = await _api.sendRequestMap(Opcode.videoChatStart, {
'conversationId': conversationId,
});
logger.i('[call] VIDEO_CHAT_START keys=${payload?.keys.toList()}');
if (payload == null) {
throw Exception('createConference: bad response');
}
final id = (payload['conversationId'] as String?) ?? conversationId;
final rawLink =
(payload['joinLink'] as String?) ?? await createJoinLink(id) ?? '';
final token = CallLink.normalizeToken(rawLink);
if (token == null) {
throw Exception('createConference: no joinLink');
}
final name = (payload['callName'] as String?)?.trim();
return CreatedCall(
conversationId: id,
joinToken: token,
callName: (name?.isEmpty ?? true) ? null : name,
chatId: payload['chatId'] is int ? payload['chatId'] as int : null,
);
}
Future<String?> createJoinLink(String conversationId) async {
if (conversationId.isEmpty) return null;
final payload = await _api.sendRequestMap(Opcode.videoChatCreateJoinLink, {
'conversationId': conversationId,
});
final link = payload?['joinLink'];
return link is String && link.isNotEmpty ? link : null;
}
String _internalParams() => jsonEncode({
'platform': 'ANDROID',
'sdkVersion': '0.2.1.3',
'clientAppKey': 'CGPGAGLGDIHBABABA',
'deviceId': _api.deviceId ?? '',
'protocolVersion': 5,
'onlyAdminCanRecord': false,
'isWaitForAdminEnabled': false,
'hexCapability': Ws2Config.defaultCapabilities,
});
Future<CallLinkPreview?> resolveCallLink(String url) async {
final token = CallLink.normalizeToken(url);
final payload = await _api.sendRequestMap(Opcode.linkInfo, {
'link': token == null ? url : CallLink.path(token),
});
if (payload == null) return null;
final vc = payload['videoConference'];
if (vc is! Map) return null;
return CallLinkPreview(
conferenceId: vc['conferenceId']?.toString(),
callName: (vc['callName'] as String?)?.trim(),
participantsCount: (vc['participantsCount'] as int?) ?? 0,
isVideo: vc['callType'] == 'VIDEO',
);
}
Future<OutgoingCallParams> joinByLink(
String token, {
bool isVideo = false,
}) async {
logger.i('[call] VIDEO_CHAT_JOIN link=$token isVideo=$isVideo');
final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, {
'joinLink': token,
'internalParams': _internalParams(),
'isVideo': isVideo,
});
logger.i('[call] VIDEO_CHAT_JOIN keys=${payload?.keys.toList()}');
if (payload == null) {
throw Exception('joinByLink: bad response');
}
final parsed = _parseCallerEndpoint(payload, const [
'internalParams',
'internalCallerParams',
], context: 'joinByLink');
return OutgoingCallParams(
conversationId: (payload['conversationId'] as String?) ?? '',
endpoint: parsed.endpoint,
callsUserId: parsed.callsUserId,
peerExternalId: 0,
isVideo: isVideo,
);
}
Future<List<CallLogEntry>> fetchHistory(
int accountId,
int currentUserId,
) async {
final payload = await _api.sendRequestMap(Opcode.videoChatHistory, {});
if (payload == null) return [];
return parseHistoryPayload(
payload,
accountId,
currentUserId,
resolver: resolveContacts,
);
}
Future<Map<int, Map<String, dynamic>>> resolveContacts(List<int> ids) async {
if (ids.isEmpty) return const {};
final out = <int, Map<String, dynamic>>{};
try {
final resp = await _api.sendRequest(Opcode.contactInfo, {
'contactIds': ids,
});
final data = resp.payload;
final contacts = data is Map ? data['contacts'] : null;
if (contacts is List) {
for (final c in contacts) {
if (c is Map) {
final id = c['id'];
if (id is int) out[id] = Map<String, dynamic>.from(c);
}
}
}
} catch (e) {
logger.w('resolveContacts: $e');
}
return out;
}
Future<bool> deleteHistory(List<int> historyIds) async {
if (historyIds.isEmpty) return true;
return _api.sendRequestOk(Opcode.videoChatDeleteHistory, {
'historyIds': historyIds,
});
}
static Future<List<CallLogEntry>> parseHistoryPayload(
Map<dynamic, dynamic> payload,
int accountId,
int currentUserId, {
Future<Map<int, Map<String, dynamic>>> Function(List<int> ids)? resolver,
}) async {
final history = payload['history'];
if (history is! List || history.isEmpty) return [];
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})>[];
for (final item in history.whereType<Map>()) {
final msg = item['message'];
if (msg is! Map) continue;
final attaches = msg['attaches'];
if (attaches is! List || attaches.isEmpty) continue;
final callAttach = attaches.firstWhere(
(a) => a is Map && a['_type'] == 'CALL',
orElse: () => null,
);
if (callAttach == null) continue;
final senderId = (msg['sender'] as int?) ?? 0;
final isOutgoing = senderId == currentUserId;
int peerId = 0;
if (isOutgoing) {
final contactIds = callAttach['contactIds'];
if (contactIds is List && contactIds.isNotEmpty) {
peerId = (contactIds.first as int?) ?? 0;
}
} else {
peerId = senderId;
}
parsed.add((
peerId: peerId,
status: _parseCallStatus(callAttach, isOutgoing),
time: (msg['time'] as int?) ?? 0,
id:
msg['id']?.toString() ??
DateTime.now().millisecondsSinceEpoch.toString(),
));
}
bool localResolved(int id) {
final c = contactsMap[id];
return c != null && c.firstName.isNotEmpty;
}
final unresolvedIds = parsed
.map((e) => e.peerId)
.where((id) => id != 0 && !localResolved(id))
.toSet()
.toList();
var fetched = const <int, Map<String, dynamic>>{};
if (unresolvedIds.isNotEmpty && resolver != null) {
fetched = await resolver(unresolvedIds);
}
final List<CallLogEntry> extractedCalls = [];
for (final e in parsed) {
final contact = contactsMap[e.peerId];
String name;
String? avatarUrl;
bool isGroup = false;
if (contact != null && contact.firstName.isNotEmpty) {
name = '${contact.firstName} ${contact.lastName ?? ''}'.trim();
avatarUrl = contact.baseUrl;
} else {
final info = fetched[e.peerId];
final resolved = _nameFromInfo(info);
if (resolved != null) {
name = resolved;
avatarUrl = (info?['baseUrl'] as String?) ?? contact?.baseUrl;
} else {
name = 'Групповой звонок';
isGroup = true;
}
}
extractedCalls.add(
CallLogEntry(
id: e.id,
accountId: accountId,
peerId: e.peerId,
name: name,
avatarUrl: avatarUrl,
status: e.status,
time: e.time,
isGroup: isGroup,
),
);
}
return extractedCalls;
}
static String? _nameFromInfo(Map<String, dynamic>? info) {
if (info == null) return null;
final names = info['names'];
if (names is List && names.isNotEmpty) {
final n = names.first;
if (n is Map) {
final full = n['name']?.toString();
if (full != null && full.isNotEmpty) return full;
final first = n['firstName']?.toString() ?? '';
final last = n['lastName']?.toString() ?? '';
final combined = '$first $last'.trim();
if (combined.isNotEmpty) return combined;
}
}
return null;
}
static CallStatus _parseCallStatus(
Map<dynamic, dynamic> callAttach,
bool isOutgoing,
) {
final hangupType = callAttach['hangupType'];
final duration = (callAttach['duration'] as int?) ?? 0;
if (isOutgoing) {
if (hangupType == 'CANCELED' || duration == 0) return CallStatus.canceled;
return CallStatus.outgoing;
} else {
if (hangupType == 'CANCELED' ||
hangupType == 'REJECTED' ||
hangupType == 'MISSED' ||
duration == 0) {
return CallStatus.missed;
}
return CallStatus.incoming;
}
}
}
+172
View File
@@ -0,0 +1,172 @@
import 'dart:async';
import '../../core/cache/info_cache.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/storage/token_storage.dart';
import '../../models/admin_rights.dart';
import '../../models/chat_info.dart';
import '../../models/chat_reaction_settings.dart';
import '../api.dart';
import 'chats.dart';
class ChatAdminModule {
final Api _api;
(int?, int)? _reactionScope;
ChatAdminModule(this._api);
late final InfoCache<ChatReactionSettings> _reactionCache = InfoCache(
ttl: const Duration(minutes: 5),
fetcher: _fetchReactionSettings,
);
final StreamController<({int chatId, ChatReactionSettings settings})>
_reactionUpdates = StreamController.broadcast();
Stream<({int chatId, ChatReactionSettings settings})> get reactionUpdates =>
_reactionUpdates.stream;
Future<ChatReactionSettings?> reactionSettingsFor(
int chatId, {
bool forceRefresh = false,
}) async {
final scope = (await TokenStorage.getActiveAccountId(), _api.sessionEpoch);
if (_reactionScope != scope) {
_reactionCache.clear();
_reactionScope = scope;
}
final result = await _reactionCache.get(chatId, forceRefresh: forceRefresh);
if ((await TokenStorage.getActiveAccountId(), _api.sessionEpoch) != scope) {
return null;
}
return result;
}
Future<ChatInfo> setAdmin(int chatId, int userId, AdminRights rights) =>
_apply(chatId, Opcode.chatMembersUpdate, {
'userIds': [userId],
'type': 'ADMIN',
'operation': 'add',
'permissions': rights.bits,
});
Future<ChatInfo> removeAdmin(int chatId, int userId) =>
_apply(chatId, Opcode.chatMembersUpdate, {
'userIds': [userId],
'type': 'ADMIN',
'operation': 'remove',
});
Future<ChatInfo> removeMember(int chatId, int userId) =>
_apply(chatId, Opcode.chatMembersUpdate, {
'userIds': [userId],
'operation': 'remove',
'cleanMsgPeriod': 0,
});
Future<ChatInfo> transferOwnership(int chatId, int userId) =>
_apply(chatId, Opcode.chatUpdate, {'changeOwnerId': userId});
Future<ChatInfo> revokeInviteLink(int chatId) =>
_apply(chatId, Opcode.chatUpdate, {'revokePrivateLink': true});
Future<ChatInfo> setOptions(int chatId, Map<String, bool> options) =>
_apply(chatId, Opcode.chatUpdate, {'options': options});
Future<ChatInfo> updateInfo(
int chatId, {
required String title,
required String description,
}) => _apply(chatId, Opcode.chatUpdate, {
'theme': title,
'description': description,
});
Future<ChatInfo> setPhoto(int chatId, String photoToken) =>
_apply(chatId, Opcode.chatUpdate, {'photoToken': photoToken});
Future<ChatReactionSettings?> _fetchReactionSettings(int chatId) async {
final packet = await _api.sendRequest(Opcode.reactionsSettingsGetByChatId, {
'chatIds': [chatId],
}, silent: true);
final payload = packet.payload;
final list = payload is Map ? payload['chatReactionsSettings'] : null;
if (list is! List) return null;
for (final entry in list) {
if (entry is Map && entry['chatId'] == chatId) {
return ChatReactionSettings.fromMap(entry);
}
}
return null;
}
Future<ChatReactionSettings> disableReactions(int chatId) =>
_applyReactions(chatId, {'chatId': chatId, 'value': false});
Future<ChatReactionSettings> setReactions(
int chatId, {
required int count,
required List<String> forbidden,
}) => _applyReactions(chatId, {
'chatId': chatId,
'value': true,
'count': count,
'reactionIds': forbidden,
'included': false,
});
Future<ChatReactionSettings> _applyReactions(
int chatId,
Map<String, dynamic> payload,
) async {
final epoch = _api.sessionEpoch;
final accountId = await TokenStorage.getActiveAccountId();
final packet = await _api.sendRequest(
Opcode.chatReactionsSettingsSet,
payload,
silent: true,
);
if (_api.sessionEpoch != epoch ||
await TokenStorage.getActiveAccountId() != accountId) {
throw StateError('Account changed during reaction settings update');
}
final data = packet.payload;
final settings = ChatReactionSettings.fromMap(
data is Map ? data['chatReactionsSettings'] : null,
);
if (settings == null) {
throw StateError('reaction settings missing from the answer');
}
_reactionCache.putValue(chatId, settings);
_reactionUpdates.add((chatId: chatId, settings: settings));
return settings;
}
Future<ChatInfo> _apply(
int chatId,
int opcode,
Map<String, dynamic> change,
) async {
final epoch = _api.sessionEpoch;
final accountId = await TokenStorage.getActiveAccountId();
final packet = await _api.sendRequest(opcode, {
'chatId': chatId,
...change,
}, silent: true);
if (_api.sessionEpoch != epoch ||
await TokenStorage.getActiveAccountId() != accountId) {
throw StateError('Account changed during chat update');
}
final payload = packet.payload;
final chat = payload is Map ? payload['chat'] : null;
if (chat is! Map) {
final refreshed = await ChatInfoFetch.get(chatId, forceRefresh: true);
if (refreshed == null) throw StateError('chat $chatId not refreshed');
return refreshed;
}
final info = ChatInfo.fromMap(Map<String, dynamic>.from(chat));
ChatInfoFetch.put(chatId, info);
if (accountId != null) await chats.cacheServerChat(chat, accountId);
return info;
}
}
+371
View File
@@ -0,0 +1,371 @@
import '../../core/utils/logger.dart';
import '../../models/chat_call.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 previous = existing[id];
final sameLastMessage =
previous != null &&
lastMessage.id != null &&
previous.lastMsgId == lastMessage.id;
final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing);
final presence = _resolvePresence(type, otherId, presenceMap);
final adminsOwner = _resolveAdmins(chat);
final pinned = _resolvePinnedMessage(chat['pinnedMessage']);
final mentionId = int.tryParse(
chat['lastMentionMessageId']?.toString() ?? '',
);
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,
lastMsgPreview: lastMessage.preview,
lastMsgSenderId:
lastMessage.senderId ??
(sameLastMessage ? previous.lastMsgSenderId : null),
lastMsgStatus: sameLastMessage ? previous.lastMsgStatus : null,
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,
pinnedMsgId: pinned.id,
pinnedMsgText: pinned.text,
pinnedMsgTime: pinned.time,
pinnedMsgIsPreview: pinned.isPreview,
lastMentionMsgId: mentionId ?? existing[id]?.lastMentionMsgId,
activeCallData: ChatCall.fromServer(chat['videoConversation'])?.encode(),
publicLink: _resolvePublicLink(chat, previous),
);
} catch (e) {
logger.e("Ошибка при парсинге чата: $e");
return null;
}
}
String? _resolvePublicLink(Map<dynamic, dynamic> chat, CachedChat? previous) {
final access = chat['access'];
if (access == null) return previous?.publicLink;
if (access != 'PUBLIC') return null;
final link = chat['link']?.toString().trim() ?? '';
return link.isEmpty ? null : link;
}
({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,
String? preview,
int? senderId,
})
_resolveLastMessage(dynamic lastMsg) {
if (lastMsg is! Map) {
return (
id: null,
time: null,
text: null,
elements: null,
preview: null,
senderId: null,
);
}
return (
id: _asIntOrNull(lastMsg['id']),
time: _asIntOrNull(lastMsg['time']),
text: messagePreviewText(lastMsg),
elements: messagePreviewElements(lastMsg),
preview: messagePreviewMedia(lastMsg),
senderId: _asIntOrNull(lastMsg['sender']),
);
}
int? _asIntOrNull(Object? value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage(
dynamic pinned,
) {
if (pinned is! Map) {
return (id: null, text: null, time: null, isPreview: false);
}
final rawId = pinned['id'];
final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '');
if (id == null) return (id: null, text: null, time: null, isPreview: false);
final preview = pinnedMessagePreview(pinned);
return (
id: id,
text: preview.text,
time: pinned['time'] as int?,
isPreview: preview.isPreview,
);
}
({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite(
Map<dynamic, dynamic> chatsConfig,
int id,
Map<int, CachedChat> existing,
) {
final config = chatsConfig[id.toString()] ?? chatsConfig[id];
final ex = existing[id];
if (config is Map) {
final configFav = config['favIndex'] as int?;
return (
favIndex: (configFav != null && configFav > 0) ? configFav : ex?.favIndex,
dontDisturbUntil: (config['dontDisturbUntil'] as int?) ?? 0,
);
}
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;
Map<dynamic, dynamic>? fallback;
for (final entry in names) {
if (entry is! Map) continue;
final name = entry.cast<dynamic, dynamic>();
fallback ??= name;
if (name['type'] == 'ONEME') return name['name'] as String?;
}
return fallback?['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.pinnedMsgId != b.pinnedMsgId) return false;
if (a.pinnedMsgText != b.pinnedMsgText) return false;
if (a.pinnedMsgTime != b.pinnedMsgTime) return false;
if (a.pinnedMsgIsPreview != b.pinnedMsgIsPreview) 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.lastMsgPreview != b.lastMsgPreview) 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.activeCallData != b.activeCallData) return false;
if (a.publicLink != b.publicLink) 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;
}
+314
View File
@@ -0,0 +1,314 @@
import 'dart:convert';
import '../../models/attachment.dart';
import '../../models/chat_preview_media.dart';
const int _maxPreviewThumbs = 3;
const int _maxThumbLength = 20000;
String? attachPreviewLabel(dynamic attaches) {
final parts = _attachPreviewParts(attaches);
if (parts == null) return null;
final detail = parts.detail;
return detail == null ? parts.label : '${parts.label}: $detail';
}
({String label, String? detail})? _attachPreviewParts(dynamic attaches) {
final first = _firstPreviewAttach(attaches);
if (first == null) return null;
final type = (first['_type'] as String? ?? '').toUpperCase();
switch (type) {
case 'PHOTO':
return (
label: _mediaAttachCount(attaches) > 1 ? 'Изображения' : 'Изображение',
detail: null,
);
case 'VIDEO':
if (_isVideoNote(first)) return (label: 'Видео-сообщение', detail: null);
return (label: 'Видео', detail: null);
case 'AUDIO':
return (label: 'Голосовое сообщение', detail: null);
case 'FILE':
return (label: 'Файл', detail: _nonEmpty(first['name']));
case 'STICKER':
return (label: 'Стикер', detail: null);
case 'SHARE':
return (label: 'Ссылка', detail: _nonEmpty(first['title']));
case 'POLL':
return (label: 'Опрос', detail: _nonEmpty(first['title']));
case 'LOCATION':
return (label: 'Геопозиция', detail: null);
case 'CONTACT':
return (label: 'Контакт', detail: null);
case 'CONTROL':
final label = _controlPreviewLabel(first);
return label == null ? null : (label: label, detail: null);
case 'INLINE_KEYBOARD':
return null;
case 'CALL':
return (label: _callPreviewLabel(first), detail: null);
default:
return (label: 'Вложение', detail: null);
}
}
ChatPreviewKind? _attachPreviewKind(Map attach) {
switch ((attach['_type'] as String? ?? '').toUpperCase()) {
case 'PHOTO':
return ChatPreviewKind.photo;
case 'VIDEO':
return _isVideoNote(attach)
? ChatPreviewKind.videoNote
: ChatPreviewKind.video;
case 'AUDIO':
return ChatPreviewKind.audio;
case 'FILE':
return ChatPreviewKind.file;
case 'STICKER':
return ChatPreviewKind.sticker;
case 'SHARE':
return ChatPreviewKind.share;
case 'POLL':
return ChatPreviewKind.poll;
case 'LOCATION':
return ChatPreviewKind.location;
case 'CONTACT':
return ChatPreviewKind.contact;
case 'CONTROL':
return ChatPreviewKind.control;
case 'INLINE_KEYBOARD':
return null;
case 'CALL':
final video = attach['callType']?.toString().toUpperCase() == 'VIDEO';
if (_isFailedCall(attach)) {
return video
? ChatPreviewKind.missedVideoCall
: ChatPreviewKind.missedCall;
}
return video ? ChatPreviewKind.videoCall : ChatPreviewKind.call;
default:
return ChatPreviewKind.other;
}
}
String _callPreviewLabel(Map attach) {
final video = attach['callType']?.toString().toUpperCase() == 'VIDEO';
if (attach['joinLink'] != null) {
return video ? 'Групповой видеозвонок' : 'Групповой звонок';
}
if (_isFailedCall(attach)) {
return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок';
}
return video ? 'Видеозвонок' : 'Звонок';
}
bool _isFailedCall(Map attach) {
final duration = (attach['duration'] as num?)?.toInt() ?? 0;
final hangup = attach['hangupType']?.toString();
return duration == 0 ||
hangup == 'CANCELED' ||
hangup == 'REJECTED' ||
hangup == 'MISSED';
}
String? _nonEmpty(dynamic raw) {
final value = raw?.toString();
return value != null && value.isNotEmpty ? value : null;
}
Map? _firstPreviewAttach(dynamic attaches) {
if (attaches is! List || attaches.isEmpty) return null;
for (final attach in attaches) {
if (attach is! Map) continue;
final type = (attach['_type'] as String? ?? '').toUpperCase();
if (type == 'INLINE_KEYBOARD') continue;
return attach;
}
return null;
}
int _mediaAttachCount(dynamic attaches) {
if (attaches is! List) return 0;
var count = 0;
for (final attach in attaches.whereType<Map>()) {
final type = (attach['_type'] as String? ?? '').toUpperCase();
if (type == 'PHOTO' || (type == 'VIDEO' && !_isVideoNote(attach))) count++;
}
return count;
}
bool _isVideoNote(Map attach) {
final raw = attach['videoType'];
if (raw is int) return raw == 1;
return raw?.toString() == '1';
}
String? _controlPreviewLabel(Map c) {
final title = c['title']?.toString();
switch (c['event']?.toString()) {
case 'new':
return 'Чат создан';
case 'title':
return title == null || title.isEmpty
? 'Название чата изменено'
: 'Название чата изменено на «$title»';
case 'icon':
return 'Фото чата обновлено';
}
if (title != null && title.isNotEmpty) return title;
final short = c['shortMessage']?.toString();
if (short != null && short.isNotEmpty) return short;
switch (c['event']?.toString()) {
case 'add':
case 'joinByLink':
return 'Новый участник';
case 'leave':
return 'Участник вышел';
case 'remove':
return 'Участник удалён';
case 'pin':
return 'Закреплённое сообщение';
default:
return 'Системное сообщение';
}
}
String? messagePreviewText(Map msg) {
final original = _forwardOrigin(msg);
if (original != null) {
final inner = original is Map ? _bodyPreviewText(original) : null;
return inner != null && inner.isNotEmpty
? '↪ $inner'
: '↪ Пересланное сообщение';
}
return _bodyPreviewText(msg);
}
String? messagePreviewMedia(Map msg) {
final origin = _forwardOrigin(msg);
final body = origin ?? msg;
if (body is! Map) return null;
final first = _firstPreviewAttach(body['attaches']);
if (first == null) return null;
final kind = _attachPreviewKind(first);
if (kind == null) return null;
final text = body['text']?.toString();
final captioned = text != null && text.isNotEmpty;
final parts = captioned ? null : _attachPreviewParts(body['attaches']);
final label = parts == null
? null
: (origin == null ? parts.label : '↪ ${parts.label}');
return ChatPreviewMedia(
kind: kind,
thumbs: _previewThumbs(body['attaches']),
label: label,
detail: parts?.detail,
).encode();
}
dynamic _forwardOrigin(Map msg) {
final link = msg['link'];
if (link is! Map) return null;
if (link['type']?.toString().toUpperCase() != 'FORWARD') return null;
return link['message'];
}
List<ChatPreviewThumb> _previewThumbs(dynamic attaches) {
if (attaches is! List) return const [];
final thumbs = <ChatPreviewThumb>[];
for (final attach in attaches.whereType<Map>()) {
if (thumbs.length >= _maxPreviewThumbs) break;
final type = (attach['_type'] as String? ?? '').toUpperCase();
final isVideo = type == 'VIDEO';
if (type != 'PHOTO' && !isVideo) continue;
final source = _thumbSource(attach, isVideo);
if (source == null) continue;
thumbs.add(ChatPreviewThumb(source: source, video: isVideo));
}
return thumbs;
}
String? _thumbSource(Map attach, bool isVideo) {
final data = decodeAttachPreview(attach['previewData']);
if (data != null && data.length <= _maxThumbLength) return data;
final url = isVideo
? _nonEmpty(attach['thumbnail'])
: _nonEmpty(attach['baseUrl']);
if (url != null && url.startsWith('http')) return url;
if (isVideo) return null;
final local = _nonEmpty(attach['localPath']);
return local == null ? null : Uri.file(local).toString();
}
({String? text, bool isPreview}) pinnedMessagePreview(Map msg) {
final link = msg['link'];
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
final original = link['message'];
if (original is Map) {
final inner = pinnedMessagePreview(original);
return inner.text != null && inner.text!.isNotEmpty
? (text: '↪ ${inner.text}', isPreview: inner.isPreview)
: (text: '↪ пересланное сообщение', isPreview: true);
}
return (text: '↪ пересланное сообщение', isPreview: true);
}
return _pinnedBodyPreview(msg);
}
({String? text, bool isPreview}) _pinnedBodyPreview(Map msg) {
final text = msg['text']?.toString();
if (text != null && text.isNotEmpty) return (text: text, isPreview: false);
final label = _pinnedAttachPreviewLabel(msg['attaches']);
return (text: label, isPreview: label != null);
}
String? _pinnedAttachPreviewLabel(dynamic attaches) {
final first = _firstPreviewAttach(attaches);
if (first == null) return null;
final type = (first['_type'] as String? ?? '').toUpperCase();
switch (type) {
case 'PHOTO':
return 'фото';
case 'VIDEO':
return _isVideoNote(first) ? 'кружок' : 'видео';
case 'AUDIO':
return 'голосовое сообщение';
case 'FILE':
return 'файл';
case 'STICKER':
return 'стикер';
case 'SHARE':
return 'ссылка';
case 'POLL':
return 'голосование';
case 'LOCATION':
return 'геопозиция';
case 'CONTACT':
return 'контакт';
case 'CALL':
return 'звонок';
case 'CONTROL':
final label = _controlPreviewLabel(first);
return label == null || label.isEmpty
? label
: label[0].toLowerCase() + label.substring(1);
default:
return 'вложение';
}
}
String? _bodyPreviewText(Map msg) {
final text = msg['text']?.toString();
if (text != null && text.isNotEmpty) return text;
return attachPreviewLabel(msg['attaches']);
}
String? messagePreviewElements(Map msg) {
final text = msg['text'];
if (text is! String || text.isEmpty) return null;
final elements = msg['elements'];
if (elements is List && elements.isNotEmpty) return jsonEncode(elements);
return null;
}
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../../models/attachment.dart';
import '../../core/protocol/opcode_map.dart';
import '../api.dart';
import 'chats.dart';
import 'messages.dart';
class CloudFile {
final String name;
final int? size;
final int time;
final int? fileId;
final String messageId;
final int chatId;
final int accountId;
const CloudFile({
required this.name,
this.size,
required this.time,
this.fileId,
required this.messageId,
required this.chatId,
required this.accountId,
});
}
class CloudStorageModule {
static const _prefix = 'CLST';
static const _tempName = 'Облачное хранилище';
// Key: "$accountId:$fileId" — scoped per account
static final Map<String, ({String url, int expires})> _linkCache = {};
static int _computeSpecialNumber(int groupId) {
final s = groupId.abs().toString();
final len = s.length;
if (len < 4) {
final n = int.parse(s);
return n + n;
}
final first = int.parse(s.substring(0, 4));
final last = int.parse(s.substring(len - 4));
return first + last;
}
static bool isCloudStorageGroup(CachedChat chat) {
if (chat.type != 'CHAT') return false;
final title = chat.title;
if (title == null || !title.startsWith(_prefix)) return false;
final numStr = title.substring(_prefix.length);
final provided = int.tryParse(numStr);
if (provided == null) return false;
return provided == _computeSpecialNumber(chat.id);
}
static CachedChat? findEnvGroup(List<CachedChat> chats) {
for (final c in chats) {
if (isCloudStorageGroup(c)) return c;
}
return null;
}
static List<CachedChat> findOrphanGroups(List<CachedChat> chats) =>
chats.where((c) => c.type == 'CHAT' && c.title == _tempName).toList();
// Env group ID cache — avoids scanning all chats on every screen open
static Future<void> cacheEnvGroupId(int accountId, int groupId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('cloud_storage_env_$accountId', groupId);
}
static Future<int?> getCachedEnvGroupId(int accountId) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt('cloud_storage_env_$accountId');
}
static Future<void> clearEnvGroupCache(int accountId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('cloud_storage_env_$accountId');
}
static Future<void> _configurePrivacy(Api api, int chatId) async {
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 chats.createGroupChat(
api,
title: _tempName,
userIds: [],
);
if (temp == null) return null;
final name = '$_prefix${_computeSpecialNumber(temp.id)}';
final ok = await chats.setChatTitle(api, chatId: temp.id, title: name);
if (!ok) return null;
await _configurePrivacy(api, temp.id);
return temp;
}
// 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 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,
int chatId, {
int count = 200,
}) async {
final msgs = await messages.fetchHistory(accountId, chatId, count: count);
return _cloudFilesFrom(msgs, chatId, accountId).toList();
}
// Fetches only the last few messages to find a newly uploaded file — avoids full 200-msg reload
static Future<CloudFile?> fetchLatestFile(
MessagesModule messages,
int accountId,
int chatId, {
int? expectedFileId,
}) async {
final msgs = await messages.fetchHistory(accountId, chatId, count: 5);
for (final file in _cloudFilesFrom(msgs, chatId, accountId)) {
if (expectedFileId == null || file.fileId == expectedFileId) {
return file;
}
}
return null;
}
static ({String url, int expires})? getCachedLink(int accountId, int fileId) {
final key = '$accountId:$fileId';
final entry = _linkCache[key];
if (entry == null) return null;
if (entry.expires <= DateTime.now().millisecondsSinceEpoch) {
_linkCache.remove(key);
return null;
}
return entry;
}
static Future<({String url, int expires})?> fetchFileUrl(
Api api, {
required int accountId,
required int fileId,
required int chatId,
required String messageId,
}) async {
try {
final packet = await api.sendRequest(Opcode.fileDownload, {
'fileId': fileId,
'chatId': chatId,
'messageId': int.tryParse(messageId) ?? messageId,
});
if (!packet.isOk) return null;
final data = packet.payload;
if (data is! Map) return null;
final url = data['url'] as String?;
if (url == null) return null;
final uri = Uri.tryParse(url);
final expiresStr = uri?.queryParameters['expires'];
final expires = int.tryParse(expiresStr ?? '') ?? 0;
final entry = (url: url, expires: expires);
_linkCache['$accountId:$fileId'] = entry;
return entry;
} catch (_) {
return null;
}
}
}
+326
View File
@@ -0,0 +1,326 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/utils/channel_comments.dart';
import '../../core/utils/logger.dart';
import '../api.dart';
import 'messages.dart' show CachedMessage;
class CommentsInfo {
final String postId;
final int? totalCount;
final int? updatedAt;
const CommentsInfo({required this.postId, this.totalCount, this.updatedAt});
factory CommentsInfo.fromPayload(String postId, Map payload) {
final raw = payload['totalCount'];
int? count;
if (raw is int) {
count = raw;
} else if (raw is String) {
count = int.tryParse(raw);
}
return CommentsInfo(
postId: postId,
totalCount: count,
updatedAt: DateTime.now().millisecondsSinceEpoch,
);
}
}
class CommentAddedEvent {
final int chatId;
final String postId;
final CachedMessage comment;
const CommentAddedEvent(this.chatId, this.postId, this.comment);
}
class CommentsModule {
final Api _api;
CommentsModule(this._api);
final ValueNotifier<int> revision = ValueNotifier<int>(0);
final _infoController =
StreamController<Map<String, CommentsInfo>>.broadcast();
Stream<Map<String, CommentsInfo>> get infoStream => _infoController.stream;
final _commentController = StreamController<CommentAddedEvent>.broadcast();
Stream<CommentAddedEvent> get commentStream => _commentController.stream;
Map<String, CommentsInfo> _info = <String, CommentsInfo>{};
Map<String, CommentsInfo> get infoSnapshot => Map.unmodifiable(_info);
CommentsInfo? infoFor(String postId) => _info[postId];
int _accountId = 0;
void dispose() {
_pushSub?.cancel();
_pushSub = null;
_infoController.close();
_commentController.close();
revision.dispose();
}
void attachPushHandlers(Api api) {
_pushSub?.cancel();
_pushSub = api.pushStream.listen(_handlePush);
}
StreamSubscription<Packet>? _pushSub;
void _handlePush(Packet packet) {
switch (packet.opcode) {
case Opcode.commentsInfo:
final payload = packet.payload;
if (payload is! Map) return;
final updates = payload['commentsInfoUpdates'];
if (updates is List) handleInfoUpdate(updates);
case Opcode.notifMessage:
_handleCommentPush(packet);
}
}
void _handleCommentPush(Packet packet) {
final payload = packet.payload;
if (payload is! Map) return;
final chatId = payload['chatId'];
if (chatId is! int) return;
final msg = payload['message'];
if (msg is! Map) return;
final link = msg['link'];
final postId =
(payload['postId'] ??
(link is Map ? link['postId'] : null) ??
msg['postId'])
?.toString();
if (postId == null || postId.isEmpty) return;
final comment = _parseComment(
msg.cast<dynamic, dynamic>(),
_accountId,
chatId,
postId,
);
if (comment == null) return;
_commentController.add(CommentAddedEvent(chatId, postId, comment));
}
void handleInfoUpdate(List updates) {
if (updates.isEmpty) return;
Map<String, CommentsInfo>? next;
for (final raw in updates) {
if (raw is! Map) continue;
final postId = raw['postId']?.toString();
final commentsInfo = raw['commentsInfo'];
if (postId == null || commentsInfo is! Map) continue;
final updated = CommentsInfo.fromPayload(
postId,
Map<String, dynamic>.from(commentsInfo.cast()),
);
next ??= Map<String, CommentsInfo>.from(_info);
next[postId] = updated;
}
if (next == null) return;
_info = next;
revision.value = revision.value + 1;
_infoController.add(Map.unmodifiable(_info));
}
Future<Map<String, CommentsInfo>?> fetchInfo({
required int accountId,
required int chatId,
required List<String> postIds,
}) async {
_accountId = accountId;
if (postIds.isEmpty) return const {};
final byPost = <String, CommentsInfo>{};
var failed = false;
for (final batch in commentsInfoBatches(postIds)) {
final updates = await _fetchInfoBatch(chatId, batch);
if (updates == null) {
failed = true;
continue;
}
handleInfoUpdate(updates);
for (final raw in updates.whereType<Map>()) {
final postId = raw['postId']?.toString();
final commentsInfo = raw['commentsInfo'];
if (postId == null || commentsInfo is! Map) continue;
byPost[postId] = CommentsInfo.fromPayload(
postId,
Map<String, dynamic>.from(commentsInfo.cast()),
);
}
}
return failed ? null : byPost;
}
Future<List?> _fetchInfoBatch(int chatId, List<String> postIds) async {
try {
final response = await _api.sendRequest(Opcode.commentsInfo, {
'chatId': chatId,
'postIds': postIds.map((id) => int.tryParse(id) ?? id).toList(),
});
if (!response.isOk) {
// #***! ответ с ошибкой повторять бессмысленно, в отличие от обрыва
logger.w('commentsInfo $chatId: ${response.payload}');
return const [];
}
final payload = response.payload;
if (payload is! Map) return const [];
final updates = payload['commentsInfoUpdates'];
return updates is List ? updates : const [];
} catch (e) {
logger.w('commentsInfo $chatId: $e');
return null;
}
}
Future<List<CachedMessage>> fetchHistory(
int accountId,
int chatId,
String postId, {
required int fromTime,
int forward = 30,
int backward = 15,
}) async {
_accountId = accountId;
final payload = <String, dynamic>{
'chatId': chatId,
'postId': int.tryParse(postId) ?? postId,
'from': fromTime,
'forward': forward,
'backward': backward,
'getMessages': true,
};
final response = await _api.sendRequest(Opcode.chatHistory, payload);
if (!response.isOk) return const [];
final data = response.payload;
if (data is! Map) return const [];
final messagesData = data['messages'];
if (messagesData is! List) return const [];
final results = <CachedMessage>[];
for (var i = 0; i < messagesData.length; i++) {
final m = messagesData[i];
if (m is! Map) continue;
final parsed = _parseComment(
m.cast<dynamic, dynamic>(),
accountId,
chatId,
postId,
);
if (parsed != null) results.add(parsed);
if (i > 0 && i % 20 == 0) await Future<void>.delayed(Duration.zero);
}
return results;
}
Future<String> sendComment(
int accountId,
int chatId,
String postId,
String text, {
bool notify = true,
int? replyToMessageId,
List<Map<String, dynamic>> elements = const [],
}) async {
_accountId = accountId;
final Object postIdField = int.tryParse(postId) ?? postId;
final message = <String, dynamic>{
'text': text,
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'elements': elements,
'attaches': [],
};
if (replyToMessageId != null) {
message['link'] = {
'type': 'REPLY',
'chatId': chatId,
'postId': postIdField,
'messageId': replyToMessageId,
};
}
final payload = <String, dynamic>{
'chatId': chatId,
'postId': postIdField,
'message': message,
'notify': notify,
};
final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) {
final raw = response.payload;
final msg = (raw is Map)
? (raw['localizedMessage'] ?? raw['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 '';
}
void sendTyping(int chatId, String postId, String type) {
unawaited(() async {
try {
await _api.sendRequest(Opcode.msgTyping, {
'chatId': chatId,
'postId': int.tryParse(postId) ?? postId,
'type': type,
});
} catch (_) {}
}());
}
CachedMessage? _parseComment(
Map<dynamic, dynamic> m,
int accountId,
int chatId,
String postId,
) {
final id = m['id']?.toString();
if (id == null) return null;
final full = Map<String, dynamic>.from(m.cast());
full['postId'] = postId;
final parsed = CachedMessage.parseAttachments(full);
final senderId = _parseIntField(m['sender']);
return CachedMessage(
id: id,
accountId: accountId,
chatId: chatId,
senderId: senderId,
text: m['text']?.toString(),
time: _parseIntField(m['time']),
status: m['status']?.toString(),
payload: full,
attachments: parsed.$1,
isControl: parsed.$2,
);
}
int _parseIntField(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return int.tryParse(value.toString()) ?? 0;
}
}
+125
View File
@@ -0,0 +1,125 @@
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
class ComplaintReason {
final int reasonId;
final String reasonTitle;
const ComplaintReason({required this.reasonId, required this.reasonTitle});
}
class ComplaintsModule {
static const int channelTypeId = 2;
static const int userTypeId = 6;
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 Packet response;
try {
response = await api.sendRequest(Opcode.complainReasonsGet, {
'complainSync': 0,
}, silent: true);
} catch (_) {
return const {};
}
if (!response.isOk) return const {};
final payload = response.payload;
if (payload is! Map) return const {};
final complains = payload['complains'];
final map = <int, List<ComplaintReason>>{};
if (complains is List) {
for (final entry in complains) {
if (entry is! Map) continue;
final typeId = entry['typeId'];
final reasons = entry['reasons'];
if (typeId is! int || reasons is! List) continue;
map[typeId] = reasons
.whereType<Map>()
.map(
(r) => ComplaintReason(
reasonId: r['reasonId'] is int ? r['reasonId'] as int : 0,
reasonTitle: r['reasonTitle']?.toString() ?? '',
),
)
.where((r) => r.reasonId != 0)
.toList();
}
}
_cache = map;
return map;
}
static Future<List<ComplaintReason>> reasonsFor(Api api, int typeId) async {
final map = await fetchReasons(api);
final forType = map[typeId];
if (forType != null && forType.isNotEmpty) return forType;
for (final list in map.values) {
if (list.isNotEmpty) return list;
}
return const [];
}
/// COMPLAIN payload. `parentId` is sent only when given (the chat of a
/// reported message); a whole channel goes without it.
static Map<String, dynamic> complaintPayload({
required int reasonId,
required int typeId,
required List<int> ids,
int? parentId,
}) => {
'reasonId': reasonId,
'typeId': typeId,
'ids': ids,
'parentId': ?parentId,
};
// #***! жалоба на канал целиком: typeId 2, в ids id чата, без parentId
static Future<bool> sendChannelComplaint(
Api api, {
required int chatId,
required int reasonId,
}) => sendComplaint(
api,
reasonId: reasonId,
typeId: channelTypeId,
ids: [chatId],
);
// #***! silent, текст ошибки покажем сами в диалоге
static Future<bool> sendComplaint(
Api api, {
required int reasonId,
required int typeId,
required List<int> ids,
int? parentId,
}) async {
final Packet response;
try {
response = await api.sendRequest(
Opcode.complain,
complaintPayload(
reasonId: reasonId,
typeId: typeId,
ids: ids,
parentId: parentId,
),
silent: true,
);
} catch (_) {
return false;
}
if (!response.isOk) return false;
final payload = response.payload;
return payload is Map && payload['success'] == true;
}
}
+804
View File
@@ -0,0 +1,804 @@
import 'package:flutter/foundation.dart';
import '../../core/cache/info_cache.dart';
import '../../core/config/debug_test.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../models/contact_info.dart';
import '../api.dart';
import 'messages.dart';
class CachedContact {
final int id;
final int accountId;
final String firstName;
final String? lastName;
final int phone;
final int? photoId;
final String? baseUrl;
final String? baseRawUrl;
final int updateTime;
final Set<String> options;
final int accountStatus;
const CachedContact({
required this.id,
required this.accountId,
required this.firstName,
this.lastName,
required this.phone,
this.photoId,
this.baseUrl,
this.baseRawUrl,
required this.updateTime,
this.options = const {},
this.accountStatus = 0,
});
bool get isOfficial => options.contains('OFFICIAL');
bool get isBot => options.contains('BOT');
bool get isServiceAccount => options.contains('SERVICE_ACCOUNT');
bool get isVerified => isOfficial;
bool get isDeleted => accountStatus != 0;
factory CachedContact.fromDbRow(Map<String, dynamic> row) => CachedContact(
id: row['id'] as int,
accountId: row['account_id'] as int,
firstName: row['first_name'] as String,
lastName: row['last_name'] as String?,
phone: row['phone'] as int,
photoId: row['photo_id'] as int?,
baseUrl: row['base_url'] as String?,
baseRawUrl: row['base_raw_url'] as String?,
updateTime: row['update_time'] as int,
options: _decodeOptions(row['options']),
accountStatus: (row['account_status'] as int?) ?? 0,
);
static Set<String> _decodeOptions(dynamic raw) {
if (raw is! String || raw.isEmpty) return const {};
return raw.split(',').where((s) => s.isNotEmpty).toSet();
}
}
class PhoneLookupResult {
final int id;
final String? name;
final String? avatarUrl;
final int phone;
const PhoneLookupResult({
required this.id,
this.name,
this.avatarUrl,
this.phone = 0,
});
}
class ContactPhotos {
final List<String> urls;
final List<int> ids;
final int total;
const ContactPhotos({
required this.urls,
this.ids = const [],
required this.total,
});
static const empty = ContactPhotos(urls: [], total: 0);
int? idAt(int index) => index >= 0 && index < ids.length ? ids[index] : null;
}
enum AddContactStatus { added, notFound, error }
class AddContactResult {
final AddContactStatus status;
final CachedContact? contact;
const AddContactResult(this.status, {this.contact});
}
class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static Future<PhoneLookupResult?> findByPhone(
Api api,
String phone, {
bool silent = false,
bool strict = false,
}) async {
final normalized = _normalizePhone(phone);
if (normalized == null) return null;
final Packet packet;
try {
packet = await api.sendRequest(Opcode.contactInfoByPhone, {
'phone': normalized,
}, silent: silent);
} on PacketError catch (e) {
if (strict && e.errorKey != 'user.not.found') rethrow;
return null;
}
if (packet.isError) {
if (strict) throw StateError('Contact lookup failed');
return null;
}
final contact = (packet.payload as Map?)?['contact'];
if (contact is! Map) return null;
final id = contact['id'];
if (id is! int) return null;
if (((contact['accountStatus'] as int?) ?? 0) != 0) return null;
primeContactCache(contact);
final payloadPhone = contact['phone'];
final resolvedPhone = payloadPhone is int && payloadPhone > 0
? payloadPhone
: int.tryParse(normalized.substring(1)) ?? 0;
ContactCache.putPhone(id, resolvedPhone);
String? name;
final names = contact['names'];
if (names is List) {
final n = names.firstWhere((e) => e is Map, orElse: () => null);
if (n is Map) {
final first =
(n['firstName'] as String?) ?? (n['name'] as String?) ?? '';
final last = (n['lastName'] as String?) ?? '';
final full = '$first $last'.trim();
if (full.isNotEmpty) name = full;
}
}
return PhoneLookupResult(
id: id,
name: name,
avatarUrl: contact['baseUrl'] as String?,
phone: resolvedPhone,
);
}
static String? _normalizePhone(String raw) {
var digits = raw.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 10 || digits.length > 15) return null;
if (digits.length == 11 && digits.startsWith('8')) {
digits = '7${digits.substring(1)}';
}
return '+$digits';
}
static Future<CachedContact?> addContact(
Api api,
int id,
String firstName, {
int phone = 0,
}) async {
final resp = await api.sendRequest(Opcode.contactUpdate, {
'action': 'ADD',
'contactId': id,
if (firstName.isNotEmpty) 'firstName': firstName,
}, silent: true);
if (!resp.isOk) return null;
final profile = await AppDatabase.loadActiveProfile();
if (profile == null) return null;
final data = resp.payload;
final contact = (data is Map && data['contact'] is Map)
? (data['contact'] as Map).cast<dynamic, dynamic>()
: null;
final row = contact != null
? _parseContact(contact, profile.id)
: {
'id': id,
'account_id': profile.id,
'first_name': firstName,
'last_name': null,
'phone': 0,
'photo_id': null,
'base_url': null,
'base_raw_url': null,
'update_time': 0,
'options': null,
'account_status': 0,
};
if (row == null) return null;
if (phone > 0 && ((row['phone'] as int?) ?? 0) == 0) {
row['phone'] = phone;
}
await AppDatabase.saveContacts([row]);
if (contact != null) primeContactCache(contact);
revision.value++;
return CachedContact.fromDbRow(row);
}
static Future<AddContactResult> addContactByPhone(
Api api, {
required String phone,
required String firstName,
String lastName = '',
int? expectedAccountId,
}) async {
final epoch = api.sessionEpoch;
if (expectedAccountId != null &&
((await AppDatabase.loadActiveProfile())?.id != expectedAccountId ||
api.sessionEpoch != epoch)) {
return const AddContactResult(AddContactStatus.error);
}
final normalized = _normalizePhone(phone);
if (normalized == null) {
return const AddContactResult(AddContactStatus.error);
}
final Packet resp;
try {
resp = await api.sendRequest(Opcode.contactAddByPhone, {
'phone': normalized,
'firstName': firstName,
'lastName': lastName,
}, silent: true);
} on PacketError catch (e) {
final notFound = e.errorKey == 'user.not.found';
return AddContactResult(
notFound ? AddContactStatus.notFound : AddContactStatus.error,
);
} catch (_) {
return const AddContactResult(AddContactStatus.error);
}
final data = resp.payload;
final contact = (data is Map && data['contact'] is Map)
? (data['contact'] as Map).cast<dynamic, dynamic>()
: null;
if (contact == null) {
return const AddContactResult(AddContactStatus.error);
}
final profile = await AppDatabase.loadActiveProfile();
if (profile == null ||
(expectedAccountId != null &&
(profile.id != expectedAccountId || api.sessionEpoch != epoch))) {
return const AddContactResult(AddContactStatus.error);
}
final row = _parseContact(contact, profile.id);
if (row == null) {
return const AddContactResult(AddContactStatus.error);
}
await AppDatabase.saveContacts([row]);
primeContactCache(contact);
revision.value++;
return AddContactResult(
AddContactStatus.added,
contact: CachedContact.fromDbRow(row),
);
}
static Future<CachedContact?> updateContact(
Api api, {
required int contactId,
required String firstName,
required String lastName,
}) async {
final epoch = api.sessionEpoch;
final profile = await AppDatabase.loadActiveProfile();
if (profile == null || api.sessionEpoch != epoch) return null;
void beforeSend() {
if (api.sessionEpoch != epoch) {
throw StateError('Сессия изменилась до сохранения контакта');
}
}
final payload = <String, dynamic>{
'contactId': contactId,
'action': 'UPDATE',
'firstName': firstName,
'lastName': lastName,
};
Packet resp;
try {
resp = await api.sendRequest(
Opcode.contactUpdate,
payload,
silent: true,
beforeSend: beforeSend,
);
} on PacketError catch (error) {
// A contact removed on another client can still be in the local cache.
// Saving its name explicitly restores that relation on IKAR.
if (error.errorKey != 'contact.not.found' ||
!await _isCurrentSession(api, epoch, profile.id)) {
return null;
}
try {
resp = await api.sendRequest(
Opcode.contactUpdate,
{...payload, 'action': 'ADD'},
silent: true,
beforeSend: beforeSend,
);
} catch (_) {
return null;
}
} catch (_) {
return null;
}
if (!resp.isOk || !await _isCurrentSession(api, epoch, profile.id)) {
return null;
}
final data = resp.payload;
final contact = (data is Map && data['contact'] is Map)
? (data['contact'] as Map).cast<dynamic, dynamic>()
: null;
if (contact == null) return null;
final row = _parseContact(contact, profile.id);
if (row == null) return null;
await AppDatabase.saveContacts([row]);
primeContactCache(contact);
ContactInfoFetch.putContact(contactId, contact);
revision.value++;
return CachedContact.fromDbRow(row);
}
static Future<bool> removeContact(Api api, int contactId) async {
try {
final packet = await api.sendRequest(Opcode.contactUpdate, {
'contactId': contactId,
'action': 'REMOVE',
}, silent: true);
if (!packet.isOk) return false;
} catch (_) {
return false;
}
final profile = await AppDatabase.loadActiveProfile();
if (profile != null) {
await AppDatabase.deleteContact(profile.id, contactId);
}
ContactCache.remove(contactId);
ContactInfo? info = ContactInfoFetch.peek(contactId);
if (info == null) {
ContactInfoFetch.invalidate(contactId);
info = await ContactInfoFetch.get(contactId, forceRefresh: true);
}
final rawNames = info?.raw['names'];
if (info != null && rawNames is List) {
final stripped = rawNames
.where((n) => !(n is Map && n['type'] == 'CUSTOM'))
.toList();
final newRaw = Map<String, dynamic>.from(info.raw)..['names'] = stripped;
ContactInfoFetch.putContact(contactId, newRaw);
primeContactCache(newRaw);
} else {
ContactInfoFetch.invalidate(contactId);
}
revision.value++;
return true;
}
static final Set<int> _blockedIds = <int>{};
static bool _blockedLoaded = false;
static Future<void>? _blockedLoading;
static int _blockedGeneration = 0;
static Set<int> get blockedIds => Set.unmodifiable(_blockedIds);
static void clearBlockedCache() {
_blockedIds.clear();
_blockedLoaded = false;
_blockedLoading = null;
_blockedGeneration++;
}
static Future<void> ensureBlockedLoaded(Api api) {
if (_blockedLoaded) return Future.value();
return _blockedLoading ??= _loadBlockedIds(
api,
).whenComplete(() => _blockedLoading = null);
}
static const int _blockedPageSize = 100;
static const int _blockedMaxPages = 20;
static Future<bool> isBlocked(Api api, int contactId) async {
await ensureBlockedLoaded(api);
return _blockedIds.contains(contactId);
}
static Future<void> _loadBlockedIds(Api api) async {
final generation = _blockedGeneration;
final ids = <int>{};
try {
for (var page = 0; page < _blockedMaxPages; page++) {
final map = await api.sendRequestMap(Opcode.contactList, {
'status': 'BLOCKED',
'count': _blockedPageSize,
'from': page * _blockedPageSize,
});
final contacts = map?['contacts'];
if (contacts is! List) return;
ids.addAll(
contacts.whereType<Map>().map((c) => c['id']).whereType<int>(),
);
if (contacts.length < _blockedPageSize) break;
}
} catch (e) {
logger.w('Не удалось получить список заблокированных: $e');
return;
}
if (generation != _blockedGeneration) return;
final changed = !setEquals(_blockedIds, ids);
_blockedIds
..clear()
..addAll(ids);
_blockedLoaded = true;
if (changed) revision.value++;
}
static Future<bool> setBlocked(Api api, int contactId, bool blocked) async {
try {
final packet = await api.sendRequest(Opcode.contactUpdate, {
'contactId': contactId,
'action': blocked ? 'BLOCK' : 'UNBLOCK',
});
if (packet.isError) return false;
} catch (e) {
logger.w('setBlocked $contactId: $e');
return false;
}
if (blocked) {
_blockedIds.add(contactId);
} else {
_blockedIds.remove(contactId);
}
ContactInfoFetch.invalidate(contactId);
revision.value++;
return true;
}
static Future<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
int accountId,
) => _applyContacts(data, accountId, complete: false);
static Future<void> applyFullContactList(
Map<dynamic, dynamic> data,
int accountId,
) => _applyContacts(data, accountId, complete: true);
static Future<void> _applyContacts(
Map<dynamic, dynamic> data,
int accountId, {
required bool complete,
Future<bool> Function()? isCurrent,
}) async {
final contacts = data['contacts'];
if (contacts is! List) {
logger.i('Контакты: сервер не прислал список (акк $accountId)');
return;
}
if (contacts.isEmpty && !complete) {
logger.i('Контакты: сервер прислал пустой список (акк $accountId)');
return;
}
final rows = <Map<String, dynamic>>[];
final parsedContacts = <Map<dynamic, dynamic>>[];
final removedIds = <int>{};
final presentIds = <int>{};
for (final raw in contacts.whereType<Map>()) {
final contact = raw.cast<dynamic, dynamic>();
parsedContacts.add(contact);
final id = contact['id'];
if (contact['status'] == 'REMOVED') {
if (id is int) removedIds.add(id);
continue;
}
if (id is int) presentIds.add(id);
final row = _parseContact(contact, accountId);
if (row != null) rows.add(row);
}
if (complete) {
final localIds = await AppDatabase.loadContactIds(accountId);
removedIds.addAll(localIds.where((id) => !presentIds.contains(id)));
}
if (isCurrent != null && !await isCurrent()) return;
if (rows.isNotEmpty) await AppDatabase.saveContacts(rows);
if (removedIds.isNotEmpty) {
await AppDatabase.deleteContacts(accountId, removedIds.toList());
}
if (isCurrent != null && !await isCurrent()) return;
for (final contact in parsedContacts) {
primeContactCache(contact);
}
if (removedIds.isNotEmpty) {
for (final id in removedIds) {
ContactCache.remove(id);
ContactInfoFetch.invalidate(id);
}
}
if (rows.isNotEmpty || removedIds.isNotEmpty) revision.value++;
logger.i(
'Контакты: получено ${contacts.length}, сохранено ${rows.length} (акк $accountId)',
);
}
static Future<void> syncFromServer(Api api, int accountId) async {
final epoch = api.sessionEpoch;
final revisionAtRequest = revision.value;
Future<bool> isCurrent() async =>
await _isCurrentSession(api, epoch, accountId) &&
revision.value == revisionAtRequest;
if (!await isCurrent()) return;
final packet = await api.sendRequest(
Opcode.contactsGet,
{'contactsSync': 0},
beforeSend: () {
if (api.sessionEpoch != epoch) {
throw StateError('Сессия изменилась до синхронизации контактов');
}
},
);
final map = packet.payload;
if (!packet.isOk || map is! Map || !await isCurrent()) return;
await _applyContacts(
map.cast<dynamic, dynamic>(),
accountId,
complete: true,
isCurrent: isCurrent,
);
}
static Future<bool> _isCurrentSession(
Api api,
int epoch,
int accountId,
) async {
final profile = await AppDatabase.loadActiveProfile();
return profile?.id == accountId && api.sessionEpoch == epoch;
}
static Future<ProfileData?> fetchSelfProfile(Api api, int accountId) async {
final map = await api.sendRequestMap(Opcode.contactInfo, {
'contactIds': [accountId],
});
final contacts = map?['contacts'];
if (contacts is! List) return null;
for (final raw in contacts.whereType<Map>()) {
if (raw['id'] != accountId) continue;
final contact = raw.cast<dynamic, dynamic>();
primeContactCache(contact);
return ProfileData.fromServerMap(contact);
}
return null;
}
static void primeContactCache(Map<dynamic, dynamic> contact) {
final id = contact['id'];
if (id is! int) return;
final phone = contact['phone'];
if (phone is int) ContactCache.putPhone(id, phone);
final names = contact['names'];
if (names is List && names.isNotEmpty) {
final nameRaw = _preferredNameEntry(names);
if (nameRaw != null) {
final firstName = (nameRaw['firstName'] as String?) ?? '';
final lastName = nameRaw['lastName'] as String?;
final fullName = (lastName != null && lastName.isNotEmpty)
? '$firstName $lastName'
: firstName;
if (fullName.isNotEmpty) ContactCache.put(id, fullName);
}
}
final baseUrl = contact['baseUrl'] as String?;
if (baseUrl != null && baseUrl.isNotEmpty) {
ContactCache.putAvatar(id, baseUrl);
}
}
static final Map<int, ContactPhotos> _photosHead = {};
static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId];
static Future<ContactPhotos> fetchPhotos(
Api api,
int contactId, {
int from = 0,
int count = 25,
}) async {
if (contactId <= 0) return ContactPhotos.empty;
final map = await api.sendRequestMap(Opcode.contactPhotos, {
'contactId': contactId,
'from': from,
'count': count,
});
if (map == null) return ContactPhotos.empty;
final rawUrls = map['urls'];
final urls = rawUrls is List
? rawUrls.whereType<String>().toList()
: <String>[];
final rawIds = map['ids'];
final ids = rawIds is List ? rawIds.whereType<int>().toList() : <int>[];
final total = map['total'] is int ? map['total'] as int : urls.length;
final photos = ContactPhotos(
urls: urls,
ids: ids.length == urls.length ? ids : const [],
total: total,
);
if (from == 0) _photosHead[contactId] = photos;
return photos;
}
static void invalidatePhotos(int contactId) {
_photosHead.remove(contactId);
}
static Future<List<CachedContact>> getContacts(
int accountId, {
bool includeDeleted = false,
}) async {
final rows = await AppDatabase.loadContacts(
accountId,
includeDeleted: includeDeleted,
);
return rows.map(CachedContact.fromDbRow).toList();
}
static Future<CachedContact?> getContact(int accountId, int id) async {
final row = await AppDatabase.loadContact(accountId, id);
return row == null ? null : CachedContact.fromDbRow(row);
}
static const List<String> _debugFirstNames = [
'Алиса',
'Борис',
'Вера',
'Глеб',
'Дарья',
'Егор',
'Жанна',
'Захар',
'Ирина',
'Кирилл',
'Лия',
'Максим',
'Нина',
'Олег',
'Полина',
'Роман',
'София',
'Тимур',
'Ульяна',
'Фёдор',
'Ханна',
'Цветана',
'Чеслав',
'Шура',
];
static const List<String> _debugLastNames = [
'Иванов',
'Петров',
'Сидоров',
'Кузнецов',
'Смирнов',
'Попов',
'Волков',
'Соколов',
'Морозов',
'Новиков',
'Фёдоров',
'Козлов',
];
static List<CachedContact> debugContacts() {
final count = DebugTest.contactCount;
final out = <CachedContact>[];
for (var i = 0; i < count; i++) {
final first = _debugFirstNames[i % _debugFirstNames.length];
final last =
_debugLastNames[(i ~/ _debugFirstNames.length) %
_debugLastNames.length];
out.add(
CachedContact(
id: 900000000 + i,
accountId: DebugTest.debugAccountId,
firstName: '$first ${i + 1}',
lastName: last,
phone: 79000000000 + i,
baseUrl: 'https://i.pravatar.cc/150?u=qlyra_debug_$i',
updateTime: 1,
options: i % 6 == 0 ? const {'OFFICIAL'} : const {},
),
);
}
return out;
}
/// Прогревает in-memory ContactCache из локальных контактов.
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
static Future<void> primeCacheFromDb(int accountId) async {
final contacts = await getContacts(accountId, includeDeleted: true);
for (final c in contacts) {
ContactCache.putPhone(c.id, c.phone);
final fullName = (c.lastName != null && c.lastName!.isNotEmpty)
? '${c.firstName} ${c.lastName}'
: c.firstName;
if (fullName.isNotEmpty) ContactCache.put(c.id, fullName);
if (c.baseUrl != null && c.baseUrl!.isNotEmpty) {
ContactCache.putAvatar(c.id, c.baseUrl);
}
}
}
static Map? _preferredNameEntry(List names) {
Map? oneme;
Map? any;
for (final n in names) {
if (n is! Map) continue;
any ??= n;
final type = n['type'];
if (type == 'CUSTOM') return n;
if (type == 'ONEME') oneme ??= n;
}
return oneme ?? any;
}
static Map<String, dynamic>? _parseContact(
Map<dynamic, dynamic> contact,
int accountId,
) {
final id = contact['id'];
if (id is! int) return null;
String firstName = '';
String? lastName;
final names = contact['names'];
if (names is List && names.isNotEmpty) {
final nameRaw = _preferredNameEntry(names);
if (nameRaw == null) return null;
firstName = (nameRaw['firstName'] as String?) ?? '';
lastName = nameRaw['lastName'] as String?;
}
final optionsRaw = contact['options'];
String? optionsStr;
if (optionsRaw is List) {
optionsStr = optionsRaw.whereType<String>().join(',');
}
return {
'id': id,
'account_id': accountId,
'first_name': firstName,
'last_name': lastName,
'phone': (contact['phone'] as int?) ?? 0,
'photo_id': contact['photoId'] as int?,
'base_url': contact['baseUrl'] as String?,
'base_raw_url': contact['baseRawUrl'] as String?,
'update_time': (contact['updateTime'] as int?) ?? 0,
'options': optionsStr,
'account_status': (contact['accountStatus'] as int?) ?? 0,
};
}
}
+429
View File
@@ -0,0 +1,429 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:device_info_plus/device_info_plus.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/spoofing_service.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../models/digital_id.dart';
import 'webapp.dart';
class DigitalIdException implements Exception {
final String code;
final String message;
final int? statusCode;
const DigitalIdException(this.code, this.message, {this.statusCode});
bool get isUnauthorized => code == 'UNAUTHORIZED';
bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK';
@override
String toString() => message;
}
class DigitalIdModule {
static const String _baseUrl = 'https://ikar.kusoft.xyz/api/ikar/digital-id';
static const String _deviceIdKey = 'digital_id_device_id';
static const String _tokenKey = 'digital_id_biometry_token';
final WebAppModule _webApp;
final HttpClient _http = HttpClient()
..connectionTimeout = const Duration(seconds: 20);
String? _webAppData;
String? _deviceId;
String? _realUserAgent;
DigitalIdModule(this._webApp);
Future<String> _ensureWebAppData({bool forceRefresh = false}) async {
if (!forceRefresh && _webAppData != null) return _webAppData!;
final launch = await _webApp.fetchDigitalId();
final data = _extractWebAppData(launch.url);
if (data == null) {
throw const DigitalIdException(
'NO_INIT_DATA',
'Не удалось получить данные авторизации Цифрового ID',
);
}
_webAppData = data;
return data;
}
String? _extractWebAppData(String url) {
final hashIndex = url.indexOf('#');
if (hashIndex < 0) return null;
final fragment = url.substring(hashIndex + 1);
final match = RegExp(
r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)',
).firstMatch(fragment);
final raw = match?.group(1);
if (raw == null || raw.isEmpty) return null;
// Фрагмент несёт WebAppData полностью percent-encoded (hash%3D…%26…);
// сервер ждёт канонический initDataRaw (hash=…&…) — как шлёт web-страница
// после decodeURIComponent. Декодируем один раз (фолбэк — сырое значение).
try {
return Uri.decodeComponent(raw);
} catch (_) {
return raw;
}
}
Future<String> deviceId() async {
// Тот же device_id, что уходит в handshake (опкод 6) — иначе сервер вернёт
// device_mismatch. Сессия обычно уже поднята к моменту открытия Цифрового ID.
final session = _webApp.sessionDeviceId;
if (session != null && session.isNotEmpty) return session;
if (_deviceId != null) return _deviceId!;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
final stored = await AppDatabase.getSyncValue(accountId, _deviceIdKey);
if (stored != null && stored.isNotEmpty) {
_deviceId = stored;
return stored;
}
}
final generated = await _generateDeviceId();
if (accountId != null) {
await AppDatabase.setSyncValue(accountId, _deviceIdKey, generated);
}
_deviceId = generated;
return generated;
}
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();
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.id}_$hex';
}
} catch (_) {}
return hex;
}
Future<String> _resolveUserAgent() async {
final spoofed = await SpoofingService.getWebViewUserAgent();
if (spoofed != null && spoofed.isNotEmpty) return spoofed;
return _realUserAgent ??= await _buildRealUserAgent();
}
Future<String> _buildRealUserAgent() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return 'Mozilla/5.0 (Linux; Android ${android.version.release}; '
'${android.model}) AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/124.0.0.0 Mobile Safari/537.36';
}
if (Platform.isIOS) {
final ios = await info.iosInfo;
final version = ios.systemVersion.replaceAll('.', '_');
return 'Mozilla/5.0 (iPhone; CPU iPhone OS $version like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 '
'Mobile/15E148 Safari/604.1';
}
} catch (_) {}
return 'Mozilla/5.0 (Linux; Android 14; K) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36';
}
Future<dynamic> _send(
String method,
String path, {
Object? body,
bool retry = true,
}) async {
final webAppData = await _ensureWebAppData();
final uri = Uri.parse('$_baseUrl$path');
final request = await _http.openUrl(method, uri);
request.headers.set('Authorization', '#WebAppData=$webAppData');
request.headers.set('Origin', 'https://ikar.kusoft.xyz');
request.headers.set('Referer', 'https://ikar.kusoft.xyz/');
request.headers.set('x-requested-with', 'ru.oneme.app');
request.headers.set('Accept', 'application/json');
request.headers.set('User-Agent', await _resolveUserAgent());
if (body != null) {
request.headers.contentType = ContentType.json;
request.add(utf8.encode(jsonEncode(body)));
}
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
if (kDebugMode) {
logger.i('[DID-native] $method $path -> ${response.statusCode}');
}
if (response.statusCode == 401 && retry) {
await _ensureWebAppData(forceRefresh: true);
return _send(method, path, body: body, retry: false);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw _errorFor(response.statusCode, text);
}
if (text.isEmpty) return null;
return jsonDecode(text);
}
DigitalIdException _errorFor(int statusCode, String body) {
String code = 'HTTP_$statusCode';
String message = 'Ошибка Цифрового ID ($statusCode)';
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
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;
}
} catch (_) {}
if (statusCode == 401) code = 'UNAUTHORIZED';
return DigitalIdException(code, message, statusCode: statusCode);
}
Map _unwrapData(dynamic decoded) {
if (decoded is Map && decoded['data'] is Map) {
return decoded['data'] as Map;
}
return decoded is Map ? decoded : const {};
}
Future<DigitalIdBiometryStatus> biometryStatus() async {
final decoded = await _send('GET', '/v2/digital-id/biometry-status');
return DigitalIdBiometryStatus.fromMap(_unwrapData(decoded));
}
Future<String> createBiometryToken({
required String deviceId,
String? photoHash,
}) async {
final decoded = await _send(
'POST',
'/v3/digital-id/create-biometry-token',
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},
);
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},
);
if (decoded is Map && decoded['status'] == 'done') {
final data = decoded['data'];
if (data is Map) return DigitalIdUserDocs.fromMap(data);
}
return null;
}
Future<DigitalIdEsiaLink> createEsiaLink() async {
final decoded = await _send('GET', '/v2/digital-id/create-esia-link');
return DigitalIdEsiaLink.fromMap(decoded is Map ? decoded : const {});
}
Future<DigitalIdVerification> verifyPhoto({
required String deviceId,
String? photoHash,
}) async {
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},
);
return _unwrapData(decoded)['shadow_mode'] == true;
} on DigitalIdException catch (e) {
if (e.statusCode == 404) return false;
rethrow;
}
}
Future<void> updateGuHashes() async {
await _send('POST', '/v2/digital-id/update-gu-hashes', body: const {});
}
Future<DigitalIdUniversalQr> userQr(String token) async {
final decoded = await _send(
'POST',
'/v3/digital-id/user-qr',
body: {'token': token},
);
return DigitalIdUniversalQr.fromMap(_unwrapData(decoded));
}
Future<DigitalIdQr> generateQr({
required String photo,
required String token,
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,
},
);
return DigitalIdQr.fromMap(_unwrapData(decoded));
}
Future<List<DigitalIdAcmsCard>> getCardsList({
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 cards = _unwrapData(decoded)['acms_cards'];
if (cards is! List) return const [];
return cards
.whereType<Map>()
.map(DigitalIdAcmsCard.fromMap)
.toList(growable: false);
}
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'},
);
}
Future<void> createLiteProfile(String deviceId) async {
await _send(
'POST',
'/v2/digital-id/create-lite-profile',
body: {'device_id': deviceId},
);
}
Future<void> deleteProfile() async {
await _send('DELETE', '/v3/digital-id/delete-profile');
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await TokenStorage.deleteSecure('${_tokenKey}_$accountId');
await AppDatabase.setSyncValue(accountId, _tokenKey, '');
}
}
Future<String?> _storedToken() async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return null;
final secureKey = '${_tokenKey}_$accountId';
final secured = await TokenStorage.readSecure(secureKey);
if (secured != null && secured.isNotEmpty) return secured;
final legacy = await AppDatabase.getSyncValue(accountId, _tokenKey);
if (legacy != null && legacy.isNotEmpty) {
await TokenStorage.writeSecure(secureKey, legacy);
await AppDatabase.setSyncValue(accountId, _tokenKey, '');
return legacy;
}
return null;
}
Future<void> _saveToken(String token) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await TokenStorage.writeSecure('${_tokenKey}_$accountId', token);
}
}
Future<String> ensureBiometryToken({String? photoHash}) async {
final existing = await _storedToken();
if (existing != null) return existing;
final id = await deviceId();
final token = await createBiometryToken(deviceId: id, photoHash: photoHash);
if (token.isNotEmpty) await _saveToken(token);
return token;
}
Future<DigitalIdUserDocs?> loadDocuments({
bool createIfMissing = false,
int attempts = 5,
}) async {
var token = await _storedToken();
if (token == null) {
if (!createIfMissing) return null;
final id = await deviceId();
token = await createBiometryToken(deviceId: id);
if (token.isEmpty) return null;
await _saveToken(token);
}
final state = await refreshUserDocs(token);
if (state.isEmpty) return null;
for (var attempt = 0; attempt < attempts; attempt++) {
final docs = await getUserDocs(state);
if (docs != null) return docs;
await Future.delayed(const Duration(seconds: 2));
}
return null;
}
Future<Map<String, dynamic>?> fetchMobileIdVerification(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != 'https') return null;
try {
final request = await _http.getUrl(uri);
request.followRedirects = true;
final response = await request.close();
final builder = BytesBuilder(copy: false);
await for (final chunk in response) {
builder.add(chunk);
}
final headers = <String, String>{};
response.headers.forEach((name, values) {
headers[name] = values.join(',');
});
return {
'statusCode': response.statusCode,
'headers': headers,
'data': base64Encode(builder.takeBytes()),
};
} catch (_) {
return null;
}
}
void reset() {
_webAppData = null;
_deviceId = null;
_realUserAgent = null;
}
}
+426
View File
@@ -0,0 +1,426 @@
import 'dart:async';
import 'dart:convert' show jsonDecode, utf8;
import 'dart:io';
import 'dart:typed_data';
import 'package:kusoft/kusoft.dart' as kb;
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/logger.dart';
import 'messages.dart';
sealed class UploadEvent {
const UploadEvent();
}
class UploadProgress extends UploadEvent {
final int sent;
final int total;
const UploadProgress({required this.sent, required this.total});
}
class UploadDone extends UploadEvent {
final int fileId;
final String? token;
final String? url;
final String filename;
final int size;
/// Server-assigned message id. Without it the optimistic message keeps its
/// local temp id and download URLs cannot be resolved until a restart.
final String? messageId;
const UploadDone({
required this.fileId,
required this.filename,
required this.size,
this.token,
this.url,
this.messageId,
});
}
class UploadError extends UploadEvent {
final String message;
const UploadError(this.message);
}
/// Оркестратор медиа-загрузок: control-plane (URL, отправка сообщения) идёт
/// обычными опкодами, data-plane (заливка на CDN) — через Rust-ядро kusoft,
/// которое стримит файл с диска (не держит его целиком в памяти).
class FileUploader {
final Api api;
final MessagesModule messages;
FileUploader({required this.api, required this.messages});
Stream<UploadEvent> upload({
required int chatId,
required File file,
required String filename,
required int totalSize,
String? text,
int? scheduledTime,
Duration autoForceAfter = const Duration(seconds: 1),
Duration overallTimeout = const Duration(minutes: 5),
Duration progressThrottle = const Duration(milliseconds: 16),
}) {
final ctrl = StreamController<UploadEvent>();
var cancelled = false;
StreamSubscription<kb.UploadEvent>? sub;
ctrl.onCancel = () {
cancelled = true;
sub?.cancel();
};
Future<void> run() async {
try {
final info = await messages.requestUploadUrl();
if (cancelled) return;
if (info == null) {
ctrl.add(const UploadError('no_upload_url'));
return;
}
final session = api.session;
if (session == null) {
ctrl.add(const UploadError('no_session'));
return;
}
unawaited(() async {
try {
await api.sendRequest(Opcode.msgTyping, {
'chatId': chatId,
'type': 'FILE',
});
} catch (_) {}
}());
var status = 0;
String? error;
final done = Completer<void>();
sub = session
.uploadFilePath(
url: info.url,
path: file.path,
filename: filename,
connection: 'close',
)
.listen(
(e) {
switch (e) {
case kb.UploadEvent_Progress(:final sent, :final total):
ctrl.add(
UploadProgress(sent: sent.toInt(), total: total.toInt()),
);
case kb.UploadEvent_Done(status: final s):
status = s;
case kb.UploadEvent_Error(:final message):
error = message;
}
},
onError: (Object err) {
error = err.toString();
if (!done.isCompleted) done.complete();
},
onDone: () {
if (!done.isCompleted) done.complete();
},
cancelOnError: true,
);
await done.future;
if (cancelled) return;
if (error != null) {
ctrl.add(UploadError(error!));
return;
}
if (status != 200 && status != 0) {
ctrl.add(UploadError('http_$status'));
return;
}
final messageId = await messages.sendFileMessage(
chatId,
info.fileId,
token: info.token,
text: text,
scheduledTime: scheduledTime,
);
if (cancelled) return;
if (messageId == null) {
ctrl.add(const UploadError('send_failed'));
return;
}
ctrl.add(
UploadDone(
fileId: info.fileId,
token: info.token,
url: info.url,
filename: filename,
size: totalSize,
messageId: messageId,
),
);
} catch (e) {
if (!cancelled) ctrl.add(UploadError(e.toString()));
} finally {
await ctrl.close();
}
}
unawaited(run());
return ctrl.stream;
}
Future<bool> uploadMediaFile(
Uri uri,
File file, {
void Function(int sent, int total)? onProgress,
Duration overallTimeout = const Duration(minutes: 5),
Duration progressThrottle = const Duration(milliseconds: 16),
}) async {
final session = api.session;
if (session == null) return false;
try {
final result = await _consume(
session.uploadFilePath(
url: uri.toString(),
path: file.path,
filename: _syntheticFilename(),
contentType: 'application/octet-stream',
connection: 'close',
),
onProgress: onProgress,
);
if (result.error != null) {
logger.w('uploadMediaFile: ${result.error}');
return false;
}
final respBody = utf8.decode(result.body, allowMalformed: true);
final hasError =
respBody.contains('error_msg') || respBody.contains('error_code');
if (result.status != 200 || hasError) {
logger.w(
'uploadMediaFile rejected: status=${result.status} '
'body=${respBody.length > 500 ? respBody.substring(0, 500) : respBody}',
);
}
return result.status == 200 && !hasError;
} catch (e) {
logger.w('uploadMediaFile: $e');
return false;
}
}
Future<String?> uploadImage(
Uri uri,
Uint8List bytes, {
String filename = 'avatar.jpg',
}) async {
final session = api.session;
if (session == null) return null;
try {
final result = await _consume(
session.uploadPhoto(
url: uri.toString(),
data: bytes,
filename: filename,
),
);
if (result.error != null || result.status != 200) {
logger.w('uploadImage: status=${result.status} error=${result.error}');
return null;
}
return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true));
} catch (e) {
logger.w('uploadImage: $e');
return null;
}
}
Future<String?> uploadPhoto(
Uri uri,
File file, {
String filename = 'photo.jpg',
void Function(int sent, int total)? onProgress,
Duration progressThrottle = const Duration(milliseconds: 16),
}) async {
final session = api.session;
if (session == null) return null;
try {
final result = await _consume(
session.uploadPhotoPath(
url: uri.toString(),
path: file.path,
filename: filename,
),
onProgress: onProgress,
);
if (result.error != null || result.status != 200) {
logger.w('uploadPhoto: status=${result.status} error=${result.error}');
return null;
}
return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true));
} catch (e) {
logger.w('uploadPhoto: $e');
return null;
}
}
Future<bool> uploadVideoFile(
Uri uri,
File file, {
void Function(int sent, int total)? onProgress,
int chunkSize = 2 * 1024 * 1024,
int concurrency = 4,
Duration overallTimeout = const Duration(minutes: 30),
}) async {
final session = api.session;
if (session == null) return false;
try {
final result = await _consume(
session.uploadVideoPath(
url: uri.toString(),
path: file.path,
chunkSize: chunkSize,
concurrency: concurrency,
),
onProgress: onProgress,
);
if (result.error != null || result.status != 200) {
logger.w(
'uploadVideoFile: status=${result.status} error=${result.error}',
);
}
return result.error == null && result.status == 200;
} catch (e) {
logger.w('uploadVideoFile: $e');
return false;
}
}
/// Загрузка видео для истории. В отличие от чата (чанковый `uploadVideoPath`
/// с GET-handshake), story-эндпоинт `su.oneme.ru/uploadVideo` ждёт **один POST
/// на весь файл** (как у оригинального клиента) и возвращает медиа-токен в теле
/// ответа — `[{"token":"..."}]`. Именно этот токен идёт в `media.token`
/// STORIES_SEND, а не токен из ответа VIDEO_UPLOAD.
Future<({bool ok, String? token})> uploadVideoWithToken(
Uri uri,
File file, {
void Function(int sent, int total)? onProgress,
}) async {
final session = api.session;
if (session == null) return (ok: false, token: null);
try {
final result = await _consume(
session.uploadFilePath(
url: uri.toString(),
path: file.path,
filename: _syntheticFilename(),
contentType: 'application/octet-stream',
connection: 'close',
),
onProgress: onProgress,
);
if (result.error != null || result.status != 200) {
logger.w(
'uploadVideoWithToken: status=${result.status} error=${result.error}',
);
return (ok: false, token: null);
}
final token = _parseVideoToken(
utf8.decode(result.body, allowMalformed: true),
);
return (ok: true, token: token);
} catch (e) {
logger.w('uploadVideoWithToken: $e');
return (ok: false, token: null);
}
}
String? _parseVideoToken(String body) {
try {
final json = jsonDecode(body);
// Сервер отвечает массивом: [{"token":"..."}]
if (json is List) {
for (final v in json) {
if (v is Map) {
final token = v['token'];
if (token is String && token.isNotEmpty) return token;
}
}
}
if (json is Map) {
for (final key in const ['videos', 'video', 'photos']) {
final node = json[key];
if (node is Map) {
for (final v in node.values) {
if (v is Map) {
final token = v['token'];
if (token is String && token.isNotEmpty) return token;
}
}
}
}
for (final key in const ['token', 'videoToken', 'photoToken']) {
final t = json[key];
if (t is String && t.isNotEmpty) return t;
}
}
} catch (e) {
logger.w('parseVideoToken: $e');
}
return null;
}
/// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог.
Future<({int status, Uint8List body, String? error})> _consume(
Stream<kb.UploadEvent> stream, {
void Function(int sent, int total)? onProgress,
}) async {
var status = 0;
var body = Uint8List(0);
String? error;
await for (final event in stream) {
switch (event) {
case kb.UploadEvent_Progress(:final sent, :final total):
onProgress?.call(sent.toInt(), total.toInt());
case kb.UploadEvent_Done(status: final s, body: final b):
status = s;
body = b;
case kb.UploadEvent_Error(:final message):
error = message;
}
}
return (status: status, body: body, error: error);
}
String _syntheticFilename() =>
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
String? _parsePhotoToken(String body) {
try {
final json = jsonDecode(body);
if (json is Map) {
final photos = json['photos'];
if (photos is Map) {
for (final v in photos.values) {
if (v is Map) {
final token = v['token'];
if (token is String && token.isNotEmpty) return token;
}
}
}
final pt = json['photoToken'];
if (pt is String && pt.isNotEmpty) return pt;
}
} catch (e) {
logger.w('parsePhotoToken: $e');
}
return null;
}
}
+566
View File
@@ -0,0 +1,566 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../api.dart';
import '../models/chat_folder.dart';
import 'chats.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
class _FoldersSnapshot {
final List<ChatFolder> folders;
final List<String> order;
final int folderSync;
const _FoldersSnapshot({
this.folders = const [],
this.order = const [],
this.folderSync = 0,
});
}
class FoldersModule {
static const _syncKey = 'chat_folders_snapshot';
static const _listReadyKey = 'chat_folders_list_ready';
static const String allChatsFolderId = 'all.chat.folder';
static const int titleMaxLength = 20;
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static StreamSubscription<Packet>? _pushSub;
static Future<void> _pushQueue = Future.value();
static void attachGlobalPushHandlers(Api api) {
_pushSub?.cancel();
_pushSub = api.pushStream
.where((p) => p.opcode == Opcode.notifFolders)
.listen(_enqueuePush);
}
static void _enqueuePush(Packet packet) {
_pushQueue = _pushQueue
.then((_) => _handleFoldersPush(packet))
.catchError((Object _) {});
}
static Future<void> _handleFoldersPush(Packet packet) async {
final payload = packet.payload;
if (payload is! Map) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
await applyPayload(accountId, payload.cast<dynamic, dynamic>());
await chats.applyFavorites(accountId);
}
static Future<void> markFoldersListReady(int accountId) async {
await AppDatabase.setSyncValue(accountId, _listReadyKey, '1');
}
static Future<bool> hasReceivedFoldersList(int accountId) async {
final ready = await AppDatabase.getSyncValue(accountId, _listReadyKey);
if (ready == '1') return true;
final snap = await AppDatabase.getSyncValue(accountId, _syncKey);
return snap != null && snap.isNotEmpty;
}
static bool isAllChatsFolder(ChatFolder f) {
if (f.id == allChatsFolderId) return true;
final t = f.title.trim().toLowerCase();
return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats';
}
static String? preferredInitialFolderId(List<ChatFolder> folders) {
if (folders.isEmpty) return null;
for (final f in folders) {
if (isAllChatsFolder(f)) return f.id;
}
return folders.first.id;
}
static String newFolderId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}
static void _sortInPlace(List<ChatFolder> folders, List<String> order) {
if (order.isEmpty) return;
final orderIndex = <String, int>{};
for (var i = 0; i < order.length; i++) {
orderIndex.putIfAbsent(order[i], () => i);
}
folders.sort((a, b) {
final aIndex = orderIndex[a.id] ?? -1;
final bIndex = orderIndex[b.id] ?? -1;
if (aIndex == -1 && bIndex == -1) return 0;
if (aIndex == -1) return 1;
if (bIndex == -1) return -1;
return aIndex.compareTo(bIndex);
});
}
static bool _matchesType(
int filter,
CachedChat chat, {
required int myId,
required Set<int> contactIds,
}) {
final isDialog = chat.type == 'DIALOG';
final isBot = isDialog && chat.options.contains('BOT');
final peerId = isDialog ? chat.id ^ myId : null;
final isSelf = peerId != null && peerId == myId;
final isContact =
isDialog && !isSelf && peerId != null && contactIds.contains(peerId);
switch (filter) {
case FolderFilter.channel:
return chat.type == 'CHANNEL';
case FolderFilter.chat:
return chat.type == 'CHAT' || chat.type == 'GROUP';
case FolderFilter.dialog:
return isDialog;
case FolderFilter.contact:
return isDialog && !isBot && isContact;
case FolderFilter.notContact:
return isDialog && !isBot && !isSelf && !isContact;
case FolderFilter.bot:
return isBot;
}
return false;
}
static bool _matchesRole(int filter, CachedChat chat, int myId) {
switch (filter) {
case FolderFilter.owner:
return chat.owner == myId;
case FolderFilter.admin:
return chat.owner == myId || chat.admins.contains(myId);
}
return false;
}
static const Set<int> _knownRestrictions = {
FolderFilter.unread,
FolderFilter.read,
FolderFilter.muted,
FolderFilter.notMuted,
};
static bool _narrowsAllChats(ChatFolder folder) =>
folder.include.isEmpty &&
folder.filters.any(
(f) => _knownRestrictions.contains(f) || FolderFilter.roles.contains(f),
);
// #***! фильтр ограничение, непрочитанные беззвучные и прочее
static bool _matchesRestriction(int filter, CachedChat chat) {
switch (filter) {
case FolderFilter.unread:
return chat.unreadCount > 0;
case FolderFilter.read:
return chat.unreadCount == 0;
case FolderFilter.muted:
return chat.isMuted;
case FolderFilter.notMuted:
return !chat.isMuted;
}
return true;
}
static bool chatMatchesFolder(
CachedChat chat,
ChatFolder folder, {
required int myId,
required Set<int> contactIds,
}) {
if (!folder.include.contains(chat.id)) {
final typeFilters = folder.filters
.where(FolderFilter.chatTypes.contains)
.toList();
if (typeFilters.isEmpty) {
if (!_narrowsAllChats(folder)) return false;
} else if (!typeFilters.any(
(f) => _matchesType(f, chat, myId: myId, contactIds: contactIds),
)) {
return false;
}
}
final roleFilters = folder.filters.where(FolderFilter.roles.contains);
if (roleFilters.isNotEmpty &&
!roleFilters.any((f) => _matchesRole(f, chat, myId))) {
return false;
}
for (final f in folder.filters.where(FolderFilter.showOnly.contains)) {
if (!_matchesRestriction(f, chat)) return false;
}
return true;
}
static List<CachedChat> chatsForFolder(
Iterable<CachedChat> chats,
ChatFolder folder, {
required int myId,
required Set<int> contactIds,
required Set<int> archivedIds,
}) => chats
.where(
(chat) =>
(!archivedIds.contains(chat.id) ||
folder.include.contains(chat.id)) &&
chatMatchesFolder(chat, folder, myId: myId, contactIds: contactIds),
)
.toList();
static List<ChatFolder> _parseFolderList(dynamic raw) {
if (raw is! List) return [];
return raw
.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();
}
static List<String>? _parseOrder(dynamic raw) {
if (raw is! List) return null;
return raw.map((e) => e.toString()).toList();
}
static int? _parseSync(dynamic raw) => raw is int ? raw : null;
static Future<_FoldersSnapshot> _loadSnapshot(int accountId) async {
final raw = await AppDatabase.getSyncValue(accountId, _syncKey);
if (raw == null || raw.isEmpty) return const _FoldersSnapshot();
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
final folders = _parseFolderList(map['folders']);
final order = _parseOrder(map['foldersOrder']) ?? const <String>[];
_sortInPlace(folders, order);
return _FoldersSnapshot(
folders: folders,
order: order,
folderSync: _parseSync(map['folderSync']) ?? 0,
);
} catch (_) {
return const _FoldersSnapshot();
}
}
static Future<void> _saveSnapshot(
int accountId,
_FoldersSnapshot snapshot,
) async {
final known = snapshot.folders.map((f) => f.id).toSet();
final ordered = snapshot.order.where(known.contains).toList();
final orderedSet = ordered.toSet();
final order = [
...ordered,
...known.where((id) => !orderedSet.contains(id)),
];
await AppDatabase.setSyncValue(
accountId,
_syncKey,
jsonEncode({
'folders': snapshot.folders.map((f) => f.toJson()).toList(),
'foldersOrder': order,
'folderSync': snapshot.folderSync,
}),
);
revision.value++;
}
static Future<List<ChatFolder>> loadFolders(int accountId) async {
return (await _loadSnapshot(accountId)).folders;
}
static Future<List<String>> loadFoldersOrder(int accountId) async {
return (await _loadSnapshot(accountId)).order;
}
static Future<int> loadFolderSync(int accountId) async {
return (await _loadSnapshot(accountId)).folderSync;
}
static Future<void> applyPayload(
int accountId,
Map<dynamic, dynamic> payload, {
bool replace = false,
}) async {
final foldersRaw = payload['folders'];
final folderRaw = payload['folder'];
final orderRaw = payload['foldersOrder'];
final syncRaw = payload['folderSync'];
if (foldersRaw == null &&
folderRaw == null &&
orderRaw == null &&
syncRaw == null) {
return;
}
final incoming = <ChatFolder>[
..._parseFolderList(foldersRaw),
if (folderRaw is Map)
ChatFolder.fromJson(Map<String, dynamic>.from(folderRaw)),
];
final current = await _loadSnapshot(accountId);
var folders = replace && foldersRaw is List
? List<ChatFolder>.from(incoming)
: _merge(current.folders, incoming);
final order = _parseOrder(orderRaw) ?? current.order;
if (orderRaw is List && order.isNotEmpty) {
final known = order.toSet();
final fresh = incoming.map((f) => f.id).toSet();
folders = folders
.where((f) => known.contains(f.id) || fresh.contains(f.id))
.toList();
}
_sortInPlace(folders, order);
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: folders,
order: order,
folderSync: _parseSync(syncRaw) ?? current.folderSync,
),
);
}
static List<ChatFolder> _merge(
List<ChatFolder> current,
List<ChatFolder> incoming,
) {
final merged = List<ChatFolder>.from(current);
for (final folder in incoming) {
final idx = merged.indexWhere((f) => f.id == folder.id);
if (idx >= 0) {
merged[idx] = folder;
} else {
merged.add(folder);
}
}
return merged;
}
static Future<void> applyFromLoginConfig(
int accountId,
Map<dynamic, dynamic> config,
) async {
final chatFolders = config['chatFolders'];
if (chatFolders is! Map) return;
if (chatFolders['FOLDERS'] == null) return;
await applyPayload(accountId, {
'folders': chatFolders['FOLDERS'],
'foldersOrder': chatFolders['foldersOrder'],
'folderSync': chatFolders['folderSync'],
}, replace: true);
await markFoldersListReady(accountId);
}
static Future<ChatFolder> createFolder(
Api api,
int accountId, {
required String title,
List<int> include = const [],
List<int> filters = const [],
List<int> options = const [],
List<int> favorites = const [],
}) {
return _sendUpdate(
api,
accountId,
id: newFolderId(),
title: title,
include: include,
filters: filters,
options: options,
favorites: favorites,
);
}
static Future<ChatFolder> updateFolder(
Api api,
int accountId,
ChatFolder folder, {
String? title,
List<int>? include,
List<int>? filters,
List<int>? options,
List<int>? favorites,
}) {
return _sendUpdate(
api,
accountId,
id: folder.id,
title: title ?? folder.title,
include: include ?? folder.include,
filters: filters ?? folder.filters,
options: options ?? folder.options,
favorites: favorites ?? folder.favorites,
);
}
static Future<ChatFolder> _sendUpdate(
Api api,
int accountId, {
required String id,
required String title,
required List<int> include,
required List<int> filters,
required List<int> options,
required List<int> favorites,
}) async {
final packet = await api.sendRequest(Opcode.foldersUpdate, {
'id': id,
'title': title.trim(),
'include': include,
'filters': filters,
'options': options,
'favorites': favorites,
});
throwIfPacketError(packet);
final data = packet.payload;
final folderJson = data is Map ? data['folder'] : null;
if (folderJson is! Map) {
throw StateError('FOLDERS_UPDATE: сервер не вернул папку');
}
await applyPayload(accountId, data.cast<dynamic, dynamic>());
return ChatFolder.fromJson(Map<String, dynamic>.from(folderJson));
}
static Future<ChatFolder> setFolderFavorites(
Api api,
int accountId,
ChatFolder folder,
List<int> favorites,
) {
return updateFolder(api, accountId, folder, favorites: favorites);
}
static Future<void> deleteFolders(
Api api,
int accountId,
List<String> folderIds,
) async {
if (folderIds.isEmpty) return;
final packet = await api.sendRequest(Opcode.foldersDelete, {
'folderIds': folderIds,
});
throwIfPacketError(packet);
final removed = folderIds.toSet();
final current = await _loadSnapshot(accountId);
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: current.folders.where((f) => !removed.contains(f.id)).toList(),
order: current.order,
folderSync: current.folderSync,
),
);
final data = packet.payload;
if (data is Map) {
await applyPayload(accountId, data.cast<dynamic, dynamic>());
}
}
static Future<void> reorderFolders(
Api api,
int accountId,
List<String> order,
) async {
if (order.isEmpty) return;
final packet = await api.sendRequest(Opcode.foldersReorder, {
'foldersOrder': order,
});
throwIfPacketError(packet);
final current = await _loadSnapshot(accountId);
final folders = List<ChatFolder>.from(current.folders);
_sortInPlace(folders, order);
final data = packet.payload;
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: folders,
order: order,
folderSync:
(data is Map ? _parseSync(data['folderSync']) : null) ??
current.folderSync,
),
);
}
static Future<List<ChatFolder>> fetchFoldersByIds(
Api api,
int accountId,
List<String> folderIds,
) async {
if (folderIds.isEmpty) return const [];
final packet = await api.sendRequest(Opcode.foldersGetById, {
'folderIds': folderIds,
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return const [];
final folders = _parseFolderList(data['folders']);
final missing = folderIds.toSet()..removeAll(folders.map((f) => f.id));
final current = await _loadSnapshot(accountId);
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: _merge(
current.folders.where((f) => !missing.contains(f.id)).toList(),
folders,
),
order: current.order,
folderSync: _parseSync(data['folderSync']) ?? current.folderSync,
),
);
return folders;
}
static Future<void> syncFromServer(Api api, int accountId) async {
try {
final packet = await api.sendRequest(Opcode.foldersGet, {
'folderSync': 0,
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is Map) {
await applyPayload(
accountId,
data.cast<dynamic, dynamic>(),
replace: true,
);
}
} finally {
await markFoldersListReady(accountId);
}
}
}
+426
View File
@@ -0,0 +1,426 @@
import 'dart:async';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/cache/message_session_cache.dart';
import '../../core/crypto/e2ee_service.dart';
import '../../core/storage/chat_encryption_store.dart';
import '../../core/utils/logger.dart';
import '../../core/storage/token_storage.dart';
import '../../main.dart';
import 'chats.dart';
import 'message_copy.dart';
import 'messages.dart';
// #***! запрос на пересылку — источник сообщений, ждёт цели/подтверждения
class ForwardRequest {
final int sourceChatId;
final String sourceChatName;
final String sourceChatIconUrl;
final String sourceChatType;
final List<CachedMessage> messages;
final bool hideSender;
ForwardRequest({
required this.sourceChatId,
required this.sourceChatName,
required this.sourceChatIconUrl,
required this.sourceChatType,
required List<CachedMessage> messages,
this.hideSender = false,
}) : messages = List.unmodifiable(messages);
late final bool canHideSender = messages.every(
(message) => MessageCopy.of(message) != null,
);
ForwardRequest withMessages(List<CachedMessage> value) =>
_copyWith(messages: value);
ForwardRequest withHideSender(bool value) => _copyWith(hideSender: value);
ForwardRequest _copyWith({List<CachedMessage>? messages, bool? hideSender}) =>
ForwardRequest(
sourceChatId: sourceChatId,
sourceChatName: sourceChatName,
sourceChatIconUrl: sourceChatIconUrl,
sourceChatType: sourceChatType,
messages: messages ?? this.messages,
hideSender: hideSender ?? this.hideSender,
);
CachedMessage optimisticForward(
CachedMessage source, {
required int myId,
required int targetChatId,
required String tempId,
required String status,
}) => MessagesModule.buildForwardMessage(
myId: myId,
targetChatId: targetChatId,
sourceChatId: sourceChatId,
source: source,
tempId: tempId,
time: DateTime.now().millisecondsSinceEpoch,
status: status,
sourceChatName: sourceChatName,
sourceChatIconUrl: sourceChatIconUrl,
sourceChatType: sourceChatType,
);
}
class ForwardCaption {
final String text;
final List<Map<String, dynamic>> elements;
const ForwardCaption(this.text, [this.elements = const []]);
bool get isEmpty => text.isEmpty;
}
class ForwardSendResult {
final Set<int> delivered;
final Map<int, int> unfinished;
const ForwardSendResult({required this.delivered, required this.unfinished});
}
class ForwardSender {
ForwardSender._();
static const int _parallelChats = 3;
static final StreamController<List<CachedMessage>> _deliveredController =
StreamController.broadcast();
static Stream<List<CachedMessage>> get delivered =>
_deliveredController.stream;
static bool isBlocked(int accountId, int chatId) =>
ChatEncryptionStore.instance.isEnabled(accountId, chatId) ||
E2eeService.instance.isOn(accountId, chatId);
static Future<void Function()> _prepareDispatch(
int accountId,
int chatId,
MessagesModule sender,
int expectedSessionEpoch, {
int? sourceChatId,
}) async {
await ChatEncryptionStore.instance.load();
await E2eeService.instance.ensureLoaded(accountId);
final preferences = await SharedPreferences.getInstance();
void check() {
if (!E2eeService.instance.isLoaded(accountId) ||
preferences.getString('active_account_id') != accountId.toString() ||
sender.sessionEpoch != expectedSessionEpoch) {
throw StateError('Сессия изменилась до пересылки');
}
if (isBlocked(accountId, chatId) ||
(sourceChatId != null && isBlocked(accountId, sourceChatId))) {
throw StateError('Пересылка в зашифрованный чат недоступна');
}
}
check();
return check;
}
static Future<ForwardSendResult> send({
required int accountId,
required List<int> chatIds,
required ForwardRequest request,
ForwardCaption caption = const ForwardCaption(''),
Map<int, int> resumeFrom = const {},
MessagesModule? sender,
}) async {
final transport = sender ?? messagesModule;
final epoch = transport.sessionEpoch;
final copies = request.hideSender
? [for (final source in request.messages) MessageCopy.of(source)]
: null;
if (request.messages.any(
(m) => m.accountId != accountId || m.e2ee != CachedMessage.e2eeNone,
) ||
(copies != null && copies.contains(null))) {
return ForwardSendResult(
delivered: const {},
unfinished: {
for (final chatId in chatIds) chatId: resumeFrom[chatId] ?? 0,
},
);
}
final pending = [...chatIds];
final delivered = <int>{};
final unfinished = <int, int>{};
Future<void> worker() async {
while (pending.isNotEmpty) {
final chatId = pending.removeLast();
final done = await _sendToChat(
accountId,
chatId,
request,
copies?.cast<MessageCopy>(),
caption,
resumeFrom[chatId] ?? 0,
transport,
epoch,
);
if (done == null) {
delivered.add(chatId);
} else {
unfinished[chatId] = done;
}
}
}
await Future.wait([
for (var i = 0; i < min(_parallelChats, chatIds.length); i++) worker(),
]);
return ForwardSendResult(delivered: delivered, unfinished: unfinished);
}
static Future<int?> _sendToChat(
int accountId,
int chatId,
ForwardRequest request,
List<MessageCopy>? copies,
ForwardCaption caption,
int from,
MessagesModule sender,
int expectedSessionEpoch,
) async {
final messages = request.messages;
final steps = messages.length + (caption.isEmpty ? 0 : 1);
final sent = <CachedMessage>[];
var step = from;
try {
for (; step < steps; step++) {
await _guard(
accountId,
chatId,
request.sourceChatId,
expectedSessionEpoch,
sender,
);
sent.add(
step == messages.length
? await _sendCaption(
accountId,
chatId,
caption,
sender,
expectedSessionEpoch,
sourceChatId: request.sourceChatId,
)
: copies == null
? await sendForward(
accountId,
chatId,
request,
messages[step],
sender: sender,
expectedSessionEpoch: expectedSessionEpoch,
)
: await _deliverCopy(
accountId,
chatId,
copies[step],
sender,
expectedSessionEpoch,
sourceChatId: request.sourceChatId,
),
);
}
return null;
} catch (e) {
logger.w('Пересылка в $chatId остановилась на шаге $step: $e');
return step;
} finally {
await _persist(sent, expectedSessionEpoch);
}
}
static Future<CachedMessage> sendForward(
int accountId,
int chatId,
ForwardRequest request,
CachedMessage source, {
CachedMessage? optimistic,
MessagesModule? sender,
int? expectedSessionEpoch,
}) async {
final transport = sender ?? messagesModule;
if (source.accountId != accountId ||
source.e2ee != CachedMessage.e2eeNone) {
throw StateError(
'Encrypted or foreign-account message cannot be forwarded',
);
}
final beforeSend = await _prepareDispatch(
accountId,
chatId,
transport,
expectedSessionEpoch ?? transport.sessionEpoch,
sourceChatId: request.sourceChatId,
);
final local =
optimistic ??
request.optimisticForward(
source,
myId: accountId,
targetChatId: chatId,
tempId: '',
status: 'sent',
);
final realId = await transport.forwardMessage(
chatId,
request.sourceChatId,
int.parse(source.id),
beforeSend: beforeSend,
);
return MessagesModule.reidentifyMessage(
local,
realId.isNotEmpty ? realId : local.id,
status: 'sent',
);
}
static Future<CachedMessage> sendCopy(
int accountId,
int chatId,
CachedMessage source, {
MessagesModule? sender,
int? expectedSessionEpoch,
}) {
if (source.accountId != accountId ||
source.e2ee != CachedMessage.e2eeNone) {
throw StateError('Encrypted or foreign-account message cannot be copied');
}
final copy = MessageCopy.of(source);
if (copy == null) throw StateError('message ${source.id} cannot be copied');
final transport = sender ?? messagesModule;
return _deliverCopy(
accountId,
chatId,
copy,
transport,
expectedSessionEpoch ?? transport.sessionEpoch,
sourceChatId: source.chatId,
);
}
static Future<CachedMessage> _deliverCopy(
int accountId,
int chatId,
MessageCopy copy,
MessagesModule sender,
int expectedSessionEpoch, {
int? sourceChatId,
}) async {
final beforeSend = await _prepareDispatch(
accountId,
chatId,
sender,
expectedSessionEpoch,
sourceChatId: sourceChatId,
);
final sent = await sender.sendMessageCopy(
chatId,
copy,
beforeSend: beforeSend,
);
return CachedMessage.fromPushPayload(accountId, chatId, sent);
}
static Future<void> recordInChatList(CachedMessage message) async {
final status = message.status ?? 'sending';
if (message.forwardedAttachment == null) {
await chats.applyOutgoingMessage(message, status: status);
return;
}
await chats.applyOutgoing(
message.accountId,
message.chatId,
messageId: message.id,
time: message.time,
text: MessagesModule.forwardPreviewText(message),
status: status,
);
}
static Future<void> _persist(List<CachedMessage> sent, int epoch) async {
final stored = [
for (final message in sent)
if (message.id.isNotEmpty) message,
];
if (stored.isEmpty) return;
final last = stored.last;
try {
await chats.storeSentMessages(last.accountId, last.chatId, stored);
await recordInChatList(last);
} catch (e) {
logger.w('Пересланное в ${last.chatId} не сохранилось: $e');
}
MessageSessionCache.append(last.accountId, last.chatId, stored);
if (api.sessionEpoch == epoch &&
await TokenStorage.getActiveAccountId() == last.accountId) {
_deliveredController.add(stored);
}
}
static Future<void> _guard(
int accountId,
int target,
int source,
int epoch,
MessagesModule sender,
) async {
if (await TokenStorage.getActiveAccountId() != accountId ||
sender.sessionEpoch != epoch) {
throw StateError('Forward account changed');
}
for (final chatId in [source, target]) {
if (ChatEncryptionStore.instance.isEnabled(accountId, chatId) ||
E2eeService.instance.isOn(accountId, chatId)) {
throw StateError('Encrypted chats cannot be forwarded');
}
}
}
static Future<CachedMessage> _sendCaption(
int accountId,
int chatId,
ForwardCaption caption,
MessagesModule sender,
int expectedSessionEpoch, {
int? sourceChatId,
}) async {
final beforeSend = await _prepareDispatch(
accountId,
chatId,
sender,
expectedSessionEpoch,
sourceChatId: sourceChatId,
);
final id = await sender.sendMessage(
accountId,
chatId,
caption.text,
elements: caption.elements,
beforeSend: beforeSend,
);
return CachedMessage(
id: id,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.text,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sent',
payload: caption.elements.isEmpty
? null
: {'text': caption.text, 'elements': caption.elements},
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'dart:async';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../api.dart';
sealed class ResolvedLink {
const ResolvedLink();
}
class ResolvedChat extends ResolvedLink {
final Map<dynamic, dynamic> chat;
final Map<dynamic, dynamic>? message;
const ResolvedChat(this.chat, this.message);
}
class ResolvedUser extends ResolvedLink {
final Map<dynamic, dynamic> contact;
const ResolvedUser(this.contact);
}
class ResolvedLinkError extends ResolvedLink {
final String message;
final String? code;
const ResolvedLinkError(this.message, {this.code});
bool get accessDenied => code == 'chat.denied';
}
abstract class LinkModule {
static Future<ResolvedLink?> resolve(Api api, String url) async {
final Packet response;
try {
response = await api.sendRequest(Opcode.linkInfo, {
'link': url,
}, silent: true);
} on TimeoutException {
return const ResolvedLinkError('Превышено время ожидания');
} on PacketError catch (e) {
return ResolvedLinkError(e.message, code: e.errorKey);
}
final payload = response.payload;
if (payload is! Map) return null;
if (!response.isOk) {
return ResolvedLinkError(
messageFromErrorPayload(payload),
code: payload['error']?.toString(),
);
}
final chat = payload['chat'];
if (chat is Map) {
final message = payload['message'];
return ResolvedChat(chat, message is Map ? message : null);
}
final user = payload['user'];
if (user is Map && user['contact'] is Map) {
return ResolvedUser(user['contact'] as Map);
}
return null;
}
static Future<String?> join(Api api, String url) async {
try {
final response = await api.sendRequest(Opcode.chatJoin, {'link': url});
if (response.isOk) return null;
return messageFromErrorPayload(response.payload);
} on TimeoutException {
return 'Превышено время ожидания';
} on PacketError catch (e) {
return e.message;
}
}
}
+167
View File
@@ -0,0 +1,167 @@
import 'dart:typed_data';
import '../../core/utils/parse.dart';
import '../../models/attachment.dart';
import 'messages.dart';
class MessageCopy {
final String text;
final List<Map<String, dynamic>> elements;
final List<Map<String, dynamic>> attaches;
final List<Map<String, dynamic>> wireAttaches;
const MessageCopy._({
required this.text,
required this.elements,
required this.attaches,
required this.wireAttaches,
});
static const _strippedTypes = {'INLINE_KEYBOARD', 'SHARE'};
static MessageCopy? of(CachedMessage message) {
if (message.isControl || message.e2ee != CachedMessage.e2eeNone) {
return null;
}
final source = _contentOf(message);
final text = source['text']?.toString() ?? '';
final attaches = <Map<String, dynamic>>[];
final wireAttaches = <Map<String, dynamic>>[];
for (final attach in _maps(source['attaches'])) {
final type = attach['_type']?.toString().toUpperCase();
if (_strippedTypes.contains(type)) continue;
final wire = _wireAttach(type, attach);
if (wire == null) return null;
attaches.add(attach);
wireAttaches.add(wire);
}
if (text.trim().isEmpty && wireAttaches.isEmpty) return null;
return MessageCopy._(
text: text,
elements: _maps(source['elements']),
attaches: attaches,
wireAttaches: wireAttaches,
);
}
CachedMessage toOutgoing({
required int accountId,
required int chatId,
required String tempId,
required int time,
}) {
final payload = <String, dynamic>{
'text': text,
'elements': elements,
'attaches': attaches,
};
final (attachments, isControl) = CachedMessage.parseAttachments(payload);
return CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: time,
status: 'sending',
payload: payload,
attachments: attachments,
isControl: isControl,
);
}
static Map<String, dynamic> _contentOf(CachedMessage message) {
final payload = message.payload;
final link = payload?['link'];
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
final original = link['message'];
return original is Map ? Map<String, dynamic>.from(original) : const {};
}
return {
'text': message.text,
'elements': payload?['elements'],
'attaches':
payload?['attaches'] ??
[
for (final attachment
in message.attachments ?? const <MessageAttachment>[])
attachment.toMap(),
],
};
}
static List<Map<String, dynamic>> _maps(Object? raw) => raw is List
? [
for (final item in raw.whereType<Map>())
Map<String, dynamic>.from(item),
]
: const [];
static Map<String, dynamic>? _wireAttach(
String? type,
Map<String, dynamic> attach,
) {
switch (type) {
case 'PHOTO':
final token = attach['photoToken'];
if (token == null) return null;
return {'_type': 'PHOTO', 'photoToken': token};
case 'VIDEO':
final token = attach['token'] ?? attach['videoToken'];
if (token == null) return null;
if (parseIntOrNull(attach['videoType']) != 1) {
return {'videoType': 0, '_type': 'VIDEO', 'token': token};
}
final thumbhash = attach['thumbhash'];
return {
'duration': ?parseIntOrNull(attach['duration']),
'videoType': 1,
'_type': 'VIDEO',
'wave': _wave(attach['wave']),
'token': token,
'thumbhash': ?thumbhash,
};
case 'AUDIO':
final token = attach['token'] ?? attach['audioToken'];
if (token == null) return null;
return {
'duration': ?parseIntOrNull(attach['duration']),
'_type': 'AUDIO',
'wave': _wave(attach['wave']),
'token': token,
};
case 'FILE':
final fileId = parseIntOrNull(attach['fileId']);
if (fileId != null) return {'_type': 'FILE', 'fileId': fileId};
final token = attach['token'] ?? attach['fileToken'];
if (token == null) return null;
return {'_type': 'FILE', 'token': token};
case 'STICKER':
final stickerId = parseIntOrNull(attach['stickerId']);
if (stickerId == null) return null;
return {'_type': 'STICKER', 'stickerId': stickerId};
case 'CONTACT':
final contactId = parseIntOrNull(attach['contactId']);
if (contactId == null) return null;
return {'_type': 'CONTACT', 'contactId': contactId};
case 'LOCATION':
final latitude = attach['latitude'];
final longitude = attach['longitude'];
if (latitude is! num || longitude is! num) return null;
final zoom = attach['zoom'];
return {
'_type': 'LOCATION',
'latitude': latitude.toDouble(),
'longitude': longitude.toDouble(),
'zoom': zoom is num ? zoom.toDouble() : 15.0,
};
default:
return null;
}
}
static Uint8List _wave(Object? raw) {
final samples = parseIntList(raw);
return samples.isEmpty ? Uint8List(80) : Uint8List.fromList(samples);
}
}
+151
View File
@@ -0,0 +1,151 @@
import '../../core/utils/format.dart';
import 'messages.dart';
typedef MessageInfoRow = ({String label, String value});
// #***! эти поля раскладываем по своим строкам, остальные скаляры payload
// дописываем как есть — новое поле сервера появится в панели само
const Set<String> _ownRows = {
'id',
'cid',
'sender',
'time',
'updateTime',
'type',
'status',
'text',
'attaches',
'elements',
'link',
'reactionInfo',
};
const Map<int, String> _e2eeNames = {
CachedMessage.e2eeText: 'text',
CachedMessage.e2eeFailed: 'failed',
CachedMessage.e2eeFile: 'file',
};
// #***! технические поля сообщения ровно теми именами, что приходят с сервера
List<MessageInfoRow> buildMessageInfoRows(CachedMessage message) {
final payload = message.payload ?? const <String, dynamic>{};
final rows = <MessageInfoRow>[];
void add(String label, Object? value) {
if (value == null) return;
final text = value.toString();
if (text.isEmpty) return;
rows.add((label: label, value: text));
}
add('id', message.id);
add('cid', payload['cid']);
add('chatId', message.chatId);
add('sender', message.senderId == 0 ? null : message.senderId);
add('type', payload['type']);
add('status', message.status);
add('time', _stamp(message.time));
add('updateTime', _stamp(_asInt(payload['updateTime'])));
add('text.length', message.text?.length);
add('attaches', _attachesSummary(message));
add('elements', _elementsSummary(payload['elements']));
add('link', _linkSummary(payload['link']));
add('reactionInfo', _reactionsSummary(payload['reactionInfo']));
add('editHistory', message.editHistory?.length);
add('e2ee', _e2eeNames[message.e2ee]);
add('typingMs', message.typingMs);
if (message.deleted) add('deleted', true);
for (final entry in payload.entries) {
if (_ownRows.contains(entry.key)) continue;
final value = entry.value;
if (value is Map || value is List) continue;
add(entry.key, value);
}
return rows;
}
String? _stamp(int? ms) {
if (ms == null || ms <= 0) return null;
final at = DateTime.fromMillisecondsSinceEpoch(ms);
return '$ms · ${formatDateTimeNumeric(at)}';
}
int? _asInt(Object? raw) {
if (raw is int) return raw;
return int.tryParse(raw?.toString() ?? '');
}
String? _attachesSummary(CachedMessage message) {
final raw = message.previewPayload['attaches'];
if (raw is! List || raw.isEmpty) return null;
return _joinCounted([
for (final attach in raw.whereType<Map>())
attach['_type']?.toString() ?? '?',
]);
}
String? _elementsSummary(Object? raw) {
if (raw is! List || raw.isEmpty) return null;
return _joinCounted([
for (final element in raw.whereType<Map>())
element['type']?.toString() ?? '?',
]);
}
// #***! повторы схлопываем в «LINK ×3», иначе разметка займёт весь экран
String? _joinCounted(List<String> values) {
if (values.isEmpty) return null;
final counts = <String, int>{};
for (final value in values) {
counts.update(value, (n) => n + 1, ifAbsent: () => 1);
}
return [
for (final entry in counts.entries)
entry.value > 1 ? '${entry.key} ×${entry.value}' : entry.key,
].join(', ');
}
String? _linkSummary(Object? raw) {
if (raw is! Map) return null;
final parts = <String>[];
final type = raw['type']?.toString();
if (type != null && type.isNotEmpty) parts.add(type);
final name = raw['chatName']?.toString();
final chatId = raw['chatId'];
if (name != null && name.isNotEmpty) {
parts.add(chatId == null ? name : '$name ($chatId)');
} else if (chatId != null) {
parts.add(chatId.toString());
}
final inner = raw['message'];
if (inner is Map) {
final innerId = inner['id']?.toString();
if (innerId != null && innerId.isNotEmpty) parts.add(innerId);
}
return parts.isEmpty ? null : parts.join(' · ');
}
String? _reactionsSummary(Object? raw) {
if (raw is! Map) return null;
final counters = raw['counters'];
if (counters is! List || counters.isEmpty) return null;
final parts = <String>[];
var total = 0;
for (final counter in counters.whereType<Map>()) {
final reaction = counter['reaction']?.toString();
if (reaction == null || reaction.isEmpty) continue;
final count = _asInt(counter['count']) ?? 0;
total += count;
parts.add('$reaction $count');
}
if (parts.isEmpty) return null;
final your = raw['yourReaction']?.toString();
final declared = _asInt(raw['totalCount']) ?? total;
return [
parts.join(', '),
'total $declared',
if (your != null && your.isNotEmpty) 'your $your',
].join(' · ');
}
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/crypto/message_decryption_cache.dart';
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';
import 'outbox_queue.dart';
import 'outgoing_text.dart';
class OutboxService {
OutboxService._();
static final OutboxService instance = OutboxService._();
OutboxQueue? _queue;
StreamSubscription<SessionState>? _subscription;
final Map<String, CachedMessage> _latest = {};
void init(
Api api,
MessagesModule messages, {
required bool Function() isAuthenticated,
}) {
if (_queue != null) {
unawaited(flush());
return;
}
_queue = OutboxQueue(
activeAccount: TokenStorage.getActiveAccountId,
isOnline: () => api.state == SessionState.online && isAuthenticated(),
sessionEpoch: () => api.sessionEpoch,
load: (accountId) async => (await AppDatabase.loadPendingMessages(
accountId,
)).map(CachedMessage.fromDbRow).toList(),
save: (message) => AppDatabase.saveMessages([message.toDbRow()]),
updatePending: (message) => AppDatabase.replacePendingMessage(
message.toDbRow(),
pendingId: message.id,
),
replace: (before, after) async {
if (!await AppDatabase.replacePendingMessage(
after.toDbRow(),
pendingId: before.id,
)) {
throw const OutboxCancelled();
}
},
send: (message, cid) async {
final epoch = api.sessionEpoch;
final preferences = await SharedPreferences.getInstance();
final stored = await AppDatabase.loadMessage(
message.accountId,
message.chatId,
message.id,
);
if (stored == null ||
stored['deleted'] != 0 ||
stored['status'] != 'pending') {
throw const OutboxCancelled();
}
void checkSession() {
if (api.sessionEpoch != epoch ||
!isAuthenticated() ||
preferences.getString('active_account_id') !=
message.accountId.toString()) {
throw StateError('Outbox account changed before dispatch');
}
}
checkSession();
return messages.sendMessage(
message.accountId,
message.chatId,
message.text!,
clientId: cid,
replyToMessageId: OutgoingText.replyId(message),
replySourceChatId: OutgoingText.replyChatId(message),
elements: OutgoingText.elements(message),
beforeSend: checkSession,
);
},
onChanged: (before, after) {
_latest[before.id] = after;
if (_latest.length > 256) _latest.remove(_latest.keys.first);
MessageDecryptionCache.instance.adopt(before.id, after.id);
chats.emitMessageSent(after.chatId, before.id, after);
unawaited(_updatePreview(after));
},
onError: (error, stack) => logger.w('Outbox: $error'),
);
_subscription = api.stateStream.listen((state) {
if (state == SessionState.online) unawaited(flush());
});
unawaited(flush());
}
Future<CachedMessage> enqueue(
CachedMessage message, {
bool createDialog = false,
String? dialogTitle,
String? dialogIconUrl,
}) async {
final queue = _queue;
if (queue == null) throw StateError('Outbox is not initialized');
await queue.enqueue(
message,
persist: createDialog
? (message) => AppDatabase.saveOutgoingMessage(
message.toDbRow(),
createDialog: true,
dialogTitle: dialogTitle,
dialogIconUrl: dialogIconUrl,
)
: null,
);
await _updatePreview(message);
return _latest.remove(message.id) ?? message;
}
Future<void> _updatePreview(CachedMessage message) async {
try {
await chats.applyOutgoing(
message.accountId,
message.chatId,
messageId: message.id,
time: message.time,
text: message.text ?? '',
status: message.status!,
elements: OutgoingText.elements(message),
);
} catch (error) {
logger.w('Outbox preview: $error');
}
}
Future<void> flush() => _queue?.flush() ?? Future.value();
String resolveMessageId(String id) => _latest[id]?.id ?? id;
Future<void> dispose() async {
_queue?.dispose();
_queue = null;
await _subscription?.cancel();
_subscription = null;
}
}
+199
View File
@@ -0,0 +1,199 @@
import 'dart:async';
import '../../core/protocol/packet.dart';
import 'messages.dart';
import 'outgoing_text.dart';
/// The user removed or changed an item after a flush loaded its snapshot.
class OutboxCancelled implements Exception {
const OutboxCancelled();
}
class OutboxQueue {
OutboxQueue({
required this.activeAccount,
required this.isOnline,
required this.sessionEpoch,
required this.load,
required this.save,
required this.updatePending,
required this.replace,
required this.send,
required this.onChanged,
required this.onError,
DateTime Function()? now,
this.retryBase = const Duration(seconds: 2),
}) : now = now ?? DateTime.now;
final Future<int?> Function() activeAccount;
final bool Function() isOnline;
final int Function() sessionEpoch;
final Future<List<CachedMessage>> Function(int) load;
final Future<void> Function(CachedMessage) save;
final Future<bool> Function(CachedMessage) updatePending;
final Future<void> Function(CachedMessage, CachedMessage) replace;
final Future<String> Function(CachedMessage, int) send;
final void Function(CachedMessage, CachedMessage) onChanged;
final void Function(Object, StackTrace) onError;
final DateTime Function() now;
final Duration retryBase;
final Map<String, CachedMessage> _receipts = {};
Timer? _timer;
Future<void>? _running;
bool _wakeAgain = false;
bool _disposed = false;
Future<void> enqueue(
CachedMessage message, {
Future<void> Function(CachedMessage)? persist,
}) async {
if (OutgoingText.metadata(message) == null) {
throw ArgumentError('Only prepared outgoing text may be queued');
}
await (persist ?? save)(message);
}
Future<void> flush() {
if (_disposed) return Future.value();
final running = _running;
if (running != null) {
_wakeAgain = true;
return running;
}
_timer?.cancel();
return _running = _flush().whenComplete(() {
_running = null;
if (_wakeAgain && !_disposed) {
_wakeAgain = false;
unawaited(flush());
}
});
}
Future<bool> _validSession(int accountId, int epoch) async {
final active = await activeAccount();
return !_disposed &&
active == accountId &&
isOnline() &&
sessionEpoch() == epoch;
}
Future<void> _flush() async {
if (!isOnline()) return;
final epoch = sessionEpoch();
try {
final accountId = await activeAccount();
if (accountId == null || !await _validSession(accountId, epoch)) return;
final rows = await load(accountId);
final blockedChats = <int>{};
for (final pending in rows) {
if (!await _validSession(accountId, epoch)) break;
if (pending.accountId != accountId ||
pending.deleted ||
blockedChats.contains(pending.chatId)) {
continue;
}
final meta = OutgoingText.metadata(pending);
if (meta == null || pending.text == null || pending.text!.isEmpty) {
final failed = pending.copyWith(status: 'error');
if (!await updatePending(failed)) continue;
if (await _validSession(accountId, epoch)) _notify(pending, failed);
continue;
}
final retryAt = meta['retryAt'] as int? ?? 0;
final remaining = retryAt - now().millisecondsSinceEpoch;
if (remaining > 0) {
blockedChats.add(pending.chatId);
_schedule(Duration(milliseconds: remaining));
continue;
}
final receiptKey =
'${pending.accountId}/${pending.chatId}/${pending.id}';
try {
var sent = _receipts[receiptKey];
if (sent == null) {
final id = await send(pending, meta['cid'] as int);
if (id.isEmpty) throw StateError('Missing server message ID');
sent = CachedMessage(
id: id,
accountId: pending.accountId,
chatId: pending.chatId,
senderId: pending.senderId,
text: pending.text,
time: pending.time,
status: 'sent',
payload: pending.payload,
sealedText: pending.sealedText,
e2ee: pending.e2ee,
);
_receipts[receiptKey] = sent;
}
await replace(pending, sent);
_receipts.remove(receiptKey);
if (await _validSession(accountId, epoch)) _notify(pending, sent);
} catch (error, stack) {
if (error is OutboxCancelled) {
_receipts.remove(receiptKey);
continue;
}
onError(error, stack);
if (!await _validSession(accountId, epoch)) return;
if (isPermanentSendFailure(error)) {
final failed = pending.withSendFailure(error);
if (!await updatePending(failed)) continue;
if (await _validSession(accountId, epoch)) _notify(pending, failed);
continue;
}
final attempts = ((meta['attempts'] as int? ?? 0) + 1).clamp(1, 30);
final delay = Duration(
milliseconds:
(retryBase.inMilliseconds * (1 << (attempts - 1).clamp(0, 5)))
.clamp(1, 60000),
);
final queued = pending.copyWith(
status: 'pending',
payload: {
...?pending.payload,
'_outbox': {
...meta,
'attempts': attempts,
'retryAt': now().add(delay).millisecondsSinceEpoch,
},
},
);
if (!await updatePending(queued)) continue;
if (await _validSession(accountId, epoch)) _notify(pending, queued);
blockedChats.add(pending.chatId);
_schedule(delay);
}
}
} catch (error, stack) {
onError(error, stack);
_schedule(retryBase);
}
}
void _notify(CachedMessage before, CachedMessage after) {
try {
onChanged(before, after);
} catch (error, stack) {
onError(error, stack);
}
}
DateTime? _scheduledAt;
void _schedule(Duration delay) {
if (_disposed) return;
final at = now().add(delay);
if (_timer?.isActive == true && _scheduledAt!.isBefore(at)) return;
_timer?.cancel();
_scheduledAt = at;
_timer = Timer(delay, () => unawaited(flush()));
}
void dispose() {
_disposed = true;
_timer?.cancel();
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'messages.dart';
class OutgoingText {
static int _lastCid = 0;
static int nextCid() {
final now = DateTime.now().microsecondsSinceEpoch;
_lastCid = now > _lastCid ? now : _lastCid + 1;
return -_lastCid;
}
static CachedMessage prepare({
required int accountId,
required int chatId,
required String wireText,
required bool encrypted,
Map<String, dynamic>? payload,
Uint8List? sealedText,
int e2ee = CachedMessage.e2eeNone,
}) {
if (accountId == 0 || wireText.isEmpty) {
throw ArgumentError('An account and prepared text are required');
}
final cid = nextCid();
return CachedMessage(
id: 'temp_outbox_${-cid}',
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: wireText,
time: DateTime.now().millisecondsSinceEpoch,
status: 'pending',
sealedText: sealedText,
e2ee: e2ee,
payload: {
...?payload,
'_outbox': {
'version': 1,
'cid': cid,
'encrypted': encrypted,
'attempts': 0,
'retryAt': 0,
},
},
);
}
static Map<String, dynamic>? metadata(CachedMessage message) {
final raw = message.payload?['_outbox'];
if (raw is! Map ||
raw['version'] != 1 ||
raw['cid'] is! int ||
(raw['cid'] as int) >= 0 ||
raw['encrypted'] is! bool) {
return null;
}
return Map<String, dynamic>.from(raw);
}
static List<Map<String, dynamic>> elements(CachedMessage message) =>
(message.payload?['elements'] as List? ?? const [])
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
static int? replyId(CachedMessage message) {
final link = message.payload?['link'];
if (link is! Map || link['type'] != 'REPLY') return null;
final original = link['message'];
return _int(original is Map ? original['id'] : link['messageId']);
}
static int? replyChatId(CachedMessage message) {
final link = message.payload?['link'];
return link is Map && link['type'] == 'REPLY' ? _int(link['chatId']) : null;
}
static int? _int(Object? value) =>
value is int ? value : int.tryParse(value?.toString() ?? '');
}
+93
View File
@@ -0,0 +1,93 @@
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 {
final Api _api;
PollsModule(this._api);
final Map<int, Poll> _cache = {};
final Set<int> _inFlight = {};
Poll? get(int pollId) => _cache[pollId];
Future<void> fetch(
int chatId,
String messageId,
int pollId, {
bool force = false,
}) async {
if (pollId == 0) return;
if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) {
return;
}
_inFlight.add(pollId);
try {
final mid = int.tryParse(messageId) ?? 0;
final response = await _api.sendRequest(Opcode.getPollUpdates, {
'chatId': chatId,
'polls': [
{'messageId': mid, 'pollId': pollId},
],
});
if (!response.isOk) return;
final data = response.payload;
if (data is! Map) return;
final polls = data['polls'];
if (polls is! List) return;
var changed = false;
for (final p in polls) {
if (p is Map) {
final poll = Poll.fromServerMap(p);
if (poll.pollId != 0) {
_cache[poll.pollId] = poll;
changed = true;
}
}
}
if (changed) notifyListeners();
} catch (e) {
logger.w('PollsModule.fetch: pollId=$pollId chatId=$chatId $e');
} finally {
_inFlight.remove(pollId);
}
}
Future<bool> vote(
int chatId,
String messageId,
int pollId,
List<int> answersIds,
) async {
try {
final response = await _api.sendRequest(Opcode.sendVote, {
'messageId': int.tryParse(messageId) ?? 0,
'chatId': chatId,
'pollId': pollId,
'answersIds': answersIds,
});
if (!response.isOk) return false;
final data = response.payload;
final state = data is Map ? data['state'] : null;
final cached = _cache[pollId];
if (state is Map && cached != null) {
_cache[pollId] = cached.withStateMap(state);
notifyListeners();
} else {
await fetch(chatId, messageId, pollId, force: true);
}
return true;
} catch (e) {
logger.w('PollsModule.vote: pollId=$pollId chatId=$chatId $e');
return false;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'dart:async';
import '../../core/cache/info_cache.dart';
import '../../core/cache/self_presence.dart';
import '../../core/config/qlyra_settings.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../api.dart';
class SelfCheckService {
SelfCheckService._();
static final SelfCheckService instance = SelfCheckService._();
static const Duration interval = Duration(seconds: 10);
Api? _api;
Timer? _timer;
void init(Api api) {
if (_api != null) return;
_api = api;
_timer?.cancel();
_timer = Timer.periodic(interval, (_) => unawaited(_check()));
}
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;
if (!QlyraSettings.selfOnlineCheck.value) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final presence = await PresenceFetch.get(accountId, forceRefresh: true);
logger.i('[SELF CHECK] id=$accountId presence=$presence');
if (presence == null) return;
SelfPresence.applySelfCheck(
online: presence['status'] == 1,
seenSeconds: presence['seen'] is int ? presence['seen'] as int : null,
);
}
}
+568
View File
@@ -0,0 +1,568 @@
import 'dart:async';
import 'dart:io';
import '../../core/media/gallery_source.dart';
import '../../core/media/share_thumbnail.dart';
import '../../core/media/video_transcoder.dart';
import '../../core/share/shared_file_uploads.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart';
import '../../models/chat_preview_media.dart';
import '../../models/shared_payload.dart';
import '../../main.dart';
import 'chats.dart';
import 'messages.dart';
import 'upload_service.dart';
class PreparedShareFile {
final SharedFile source;
final String? thumbDataUri;
final int? width;
final int? height;
final int? durationMs;
const PreparedShareFile({
required this.source,
this.thumbDataUri,
this.width,
this.height,
this.durationMs,
});
SharedFileKind get kind => source.kind;
File get file => source.file;
}
class PreparedShare {
final List<PreparedShareFile> files;
final String? text;
const PreparedShare({required this.files, this.text});
List<PreparedShareFile> get photos =>
files.where((f) => f.kind == SharedFileKind.photo).toList();
List<PreparedShareFile> get videos =>
files.where((f) => f.kind == SharedFileKind.video).toList();
List<PreparedShareFile> get documents =>
files.where((f) => f.kind == SharedFileKind.file).toList();
bool get isTextOnly => files.isEmpty;
static Future<PreparedShare> prepare(SharedPayload payload) async {
final prepared = <PreparedShareFile>[];
for (final source in payload.files) {
prepared.add(await _prepareOne(source));
}
return PreparedShare(files: prepared, text: payload.text);
}
static Future<PreparedShareFile> _prepareOne(SharedFile source) async {
final thumb = await sharedThumbnailDataUri(source);
switch (source.kind) {
case SharedFileKind.photo:
final dim = await imageFileDimensions(source.file);
return PreparedShareFile(
source: source,
thumbDataUri: thumb,
width: dim?.$1,
height: dim?.$2,
);
case SharedFileKind.video:
VideoInfo? info;
try {
info = await VideoTranscoder.probe(source.path);
} catch (e) {
logger.w('Поделиться: probe ${source.path}: $e');
}
return PreparedShareFile(
source: source,
thumbDataUri: thumb,
width: (info?.width ?? 0) > 0 ? info!.width : null,
height: (info?.height ?? 0) > 0 ? info!.height : null,
durationMs: (info?.durationMs ?? 0) > 0 ? info!.durationMs : null,
);
case SharedFileKind.file:
return PreparedShareFile(source: source);
}
}
}
class ShareSendResult {
final int chatCount;
final int messageCount;
const ShareSendResult({required this.chatCount, required this.messageCount});
}
class ShareSender {
ShareSender._();
static final Map<
String,
({int accountId, int chatId, String text, String? preview, int time})
>
_tracked = {};
static StreamSubscription<UploadJobEvent>? _sub;
static void _track(
String tempId, {
required int accountId,
required int chatId,
required String text,
required String? preview,
required int time,
}) {
_sub ??= UploadService.instance.events.listen(_onUploadEvent);
_tracked[tempId] = (
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: time,
);
}
static void _onUploadEvent(UploadJobEvent event) {
final entry = _tracked.remove(event.tempId);
if (entry == null) return;
final status = event is UploadJobDone ? 'sent' : 'error';
final messageId = event is UploadJobDone
? (event.message?.id ?? event.tempId)
: event.tempId;
unawaited(
chats
.applyOutgoing(
entry.accountId,
entry.chatId,
messageId: messageId,
time: entry.time,
text: entry.text,
status: status,
preview: entry.preview,
)
.catchError((Object e) {
logger.w('Поделиться: не обновить превью чата ${entry.chatId}: $e');
}),
);
}
static Future<ShareSendResult> send({
required int accountId,
required List<int> chatIds,
required PreparedShare share,
required String caption,
}) async {
return SharedFileUploads.instance.use(
share.files.map((file) => file.file.path),
() async {
var messages = 0;
for (final chatId in chatIds) {
messages += await _sendToChat(
accountId: accountId,
chatId: chatId,
share: share,
caption: caption,
);
}
logger.i(
'Поделиться: подготовлено $messages сообщений в ${chatIds.length} чатов',
);
return ShareSendResult(
chatCount: chatIds.length,
messageCount: messages,
);
},
);
}
static Future<int> _sendToChat({
required int accountId,
required int chatId,
required PreparedShare share,
required String caption,
}) async {
if (share.isTextOnly) {
final text = caption.trim().isNotEmpty
? caption
: (share.text ?? '').trim();
if (text.isEmpty) return 0;
await _sendText(accountId: accountId, chatId: chatId, text: text);
return 1;
}
final photos = share.photos;
final videos = share.videos;
final documents = share.documents;
var used = false;
String take() {
if (used || caption.isEmpty) return '';
used = true;
return caption;
}
var count = 0;
if (photos.isNotEmpty) {
await _sendPhotos(
accountId: accountId,
chatId: chatId,
photos: photos,
caption: take(),
);
count++;
}
for (final video in videos) {
await _sendVideo(
accountId: accountId,
chatId: chatId,
video: video,
caption: take(),
);
count++;
}
for (final document in documents) {
await _sendDocument(
accountId: accountId,
chatId: chatId,
document: document,
caption: take(),
);
count++;
}
return count;
}
static Future<void> _sendText({
required int accountId,
required int chatId,
required String text,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: now,
status: 'sending',
);
await _persist(placeholder);
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: null,
status: 'sending',
);
String realId = tempId;
var status = 'sent';
String? failure;
try {
final sent = await messagesModule.sendMessage(accountId, chatId, text);
if (sent.isNotEmpty) realId = sent;
} catch (e) {
logger.w('Поделиться: текст в $chatId не ушёл: $e');
status = 'error';
failure = e.toString();
}
final settled = CachedMessage(
id: realId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: now,
status: status,
payload: failure == null ? null : {'_sendFailure': failure},
);
await _persist(settled, removeId: realId == tempId ? null : tempId);
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: realId,
time: now,
text: text,
preview: null,
status: status,
);
}
static Future<void> _sendPhotos({
required int accountId,
required int chatId,
required List<PreparedShareFile> photos,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final jobs = <({File file, GalleryItem? item})>[];
final attachments = <PhotoAttachment>[];
for (final photo in photos) {
jobs.add((file: photo.file, item: null));
attachments.add(
PhotoAttachment(
localPath: photo.file.path,
previewData: photo.thumbDataUri,
width: photo.width,
height: photo.height,
),
);
}
if (jobs.isEmpty) return;
final label = photos.length > 1 ? 'Изображения' : 'Изображение';
final preview = _preview(
kind: ChatPreviewKind.photo,
files: photos,
label: caption.isEmpty ? label : null,
);
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: attachments,
);
await _persist(placeholder);
final text = caption.isEmpty ? label : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
SharedFileUploads.instance.use(
jobs.map((job) => job.file.path),
() => UploadService.instance.sendPhotos(
accountId: accountId,
chatId: chatId,
tempId: tempId,
jobs: jobs,
caption: caption,
placeholder: placeholder,
),
),
);
}
static Future<void> _sendVideo({
required int accountId,
required int chatId,
required PreparedShareFile video,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final preview = _preview(
kind: ChatPreviewKind.video,
files: [video],
label: caption.isEmpty ? 'Видео' : null,
);
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: [
VideoAttachment(
localPath: video.file.path,
previewData: video.thumbDataUri,
width: video.width,
height: video.height,
duration: video.durationMs,
),
],
);
await _persist(placeholder);
final text = caption.isEmpty ? 'Видео' : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
SharedFileUploads.instance.use(
[video.file.path],
() => UploadService.instance.sendVideo(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: video.file,
caption: caption,
placeholder: placeholder,
),
),
);
}
static Future<void> _sendDocument({
required int accountId,
required int chatId,
required PreparedShareFile document,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final name = document.source.name;
final size = document.source.size;
final preview = ChatPreviewMedia(
kind: ChatPreviewKind.file,
label: caption.isEmpty ? 'Файл' : null,
detail: caption.isEmpty ? name : null,
).encode();
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: [FileAttachment(name: name, size: size)],
);
await _persist(placeholder);
final text = caption.isEmpty ? 'Файл: $name' : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
SharedFileUploads.instance.use(
[document.file.path],
() => UploadService.instance.sendFile(
accountId: accountId,
chatId: chatId,
tempId: tempId,
source: document.file,
filename: name,
size: size,
placeholder: placeholder,
),
),
);
}
static String? _preview({
required ChatPreviewKind kind,
required List<PreparedShareFile> files,
String? label,
}) {
final thumbs = <ChatPreviewThumb>[];
for (final file in files) {
final data = file.thumbDataUri;
if (data == null) continue;
thumbs.add(
ChatPreviewThumb(
source: data,
video: file.kind == SharedFileKind.video,
),
);
if (thumbs.length >= 3) break;
}
if (thumbs.isEmpty && label == null) return null;
return ChatPreviewMedia(kind: kind, thumbs: thumbs, label: label).encode();
}
static Future<void> _persist(
CachedMessage message, {
String? removeId,
}) async {
try {
if (removeId != null && removeId != message.id) {
await AppDatabase.deleteMessage(
message.accountId,
message.chatId,
removeId,
);
}
await AppDatabase.saveMessages([message.toDbRow()]);
} catch (e) {
logger.w('Поделиться: не сохранить плейсхолдер: $e');
}
}
static Future<void> _bumpChat({
required int accountId,
required int chatId,
required String messageId,
required int time,
required String text,
required String? preview,
required String status,
}) async {
try {
await chats.applyOutgoing(
accountId,
chatId,
messageId: messageId,
time: time,
text: text,
status: status,
preview: preview,
);
} catch (e) {
logger.w('Поделиться: не обновить строку чата $chatId: $e');
}
}
}
+340
View File
@@ -0,0 +1,340 @@
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart';
import '../api.dart';
const Map<String, AttachmentType> _attachTypeByName = {
'PHOTO': AttachmentType.photo,
'VIDEO': AttachmentType.video,
'AUDIO': AttachmentType.audio,
'FILE': AttachmentType.file,
'SHARE': AttachmentType.share,
};
class SharedMediaItem {
final String messageId;
final int chatId;
final int senderId;
final int time;
final MessageAttachment attachment;
final String? text;
const SharedMediaItem({
required this.messageId,
required this.chatId,
required this.senderId,
required this.time,
required this.attachment,
this.text,
});
String get dedupKey {
final a = attachment;
final String tail;
if (a is PhotoAttachment) {
tail = 'p${a.photoId ?? a.baseUrl}';
} else if (a is VideoAttachment) {
tail = 'v${a.videoId ?? a.baseUrl}';
} else if (a is FileAttachment) {
tail = 'f${a.fileId ?? a.name}';
} else if (a is AudioAttachment) {
tail = 'a${a.audioId ?? a.fileUrl}';
} else if (a is ShareAttachment) {
tail = 's${a.shareId ?? a.url}';
} else {
tail = a.hashCode.toString();
}
return '$messageId:$tail';
}
}
class SharedMediaPage {
final List<SharedMediaItem> items;
final int total;
const SharedMediaPage({required this.items, required this.total});
static const empty = SharedMediaPage(items: [], total: 0);
}
class CommonChatEntry {
final int id;
final String type;
final String title;
final String? iconUrl;
final int participantsCount;
final List<int> participantIds;
const CommonChatEntry({
required this.id,
required this.type,
required this.title,
required this.iconUrl,
required this.participantsCount,
required this.participantIds,
});
factory CommonChatEntry.fromMap(Map<String, dynamic> map) {
final participants = map['participants'];
final ids = <int>[];
if (participants is Map) {
for (final key in participants.keys) {
final id = key is int ? key : int.tryParse(key.toString());
if (id != null) ids.add(id);
}
}
return CommonChatEntry(
id: (map['id'] as num?)?.toInt() ?? 0,
type: map['type']?.toString() ?? 'CHAT',
title: map['title']?.toString() ?? '',
iconUrl: map['baseIconUrl'] as String?,
participantsCount:
(map['participantsCount'] as num?)?.toInt() ?? ids.length,
participantIds: ids,
);
}
}
class ChatMediaFeed {
final List<SharedMediaItem> items;
final int total;
final bool reachedEnd;
const ChatMediaFeed({
required this.items,
required this.total,
required this.reachedEnd,
});
}
class _ChatMediaIndex {
final List<SharedMediaItem> items = [];
final Set<String> seen = {};
int total = 0;
bool reachedEnd = false;
bool started = false;
Future<void>? inFlight;
}
String mediaDedupKey(String messageId, MessageAttachment attachment) {
if (attachment is PhotoAttachment) {
return '$messageId:p${attachment.photoId ?? attachment.baseUrl}';
}
if (attachment is VideoAttachment) {
return '$messageId:v${attachment.videoId ?? attachment.baseUrl}';
}
return '$messageId:${attachment.hashCode}';
}
class SharedContentModule {
static const int _mediaIndexPageSize = 60;
static const int _mediaIndexMaxPages = 40;
static final Map<int, _ChatMediaIndex> _mediaIndexes = {};
final Api _api;
SharedContentModule(this._api);
static void clearMediaIndex() => _mediaIndexes.clear();
Future<ChatMediaFeed?> mediaFeedFor({
required int chatId,
required String mediaKey,
required Future<String?> Function() resolveAnchor,
}) async {
final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new);
for (var page = 0; page < _mediaIndexMaxPages; page++) {
if (index.seen.contains(mediaKey)) return _snapshot(index);
if (index.reachedEnd) return null;
await _nextMediaPage(chatId, index, resolveAnchor);
}
return null;
}
Future<ChatMediaFeed> loadMoreMedia({
required int chatId,
required Future<String?> Function() resolveAnchor,
}) async {
final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new);
if (!index.reachedEnd) {
await _nextMediaPage(chatId, index, resolveAnchor);
}
return _snapshot(index);
}
ChatMediaFeed _snapshot(_ChatMediaIndex index) {
final counted = index.items.length;
final total = index.reachedEnd
? counted
: (index.total > counted ? index.total : counted);
return ChatMediaFeed(
items: List.unmodifiable(index.items),
total: total,
reachedEnd: index.reachedEnd,
);
}
Future<void> _nextMediaPage(
int chatId,
_ChatMediaIndex index,
Future<String?> Function() resolveAnchor,
) async {
final pending = index.inFlight;
if (pending != null) {
await pending;
return;
}
final task = _loadMediaPage(chatId, index, resolveAnchor);
index.inFlight = task;
try {
await task;
} finally {
index.inFlight = null;
}
}
Future<void> _loadMediaPage(
int chatId,
_ChatMediaIndex index,
Future<String?> Function() resolveAnchor,
) async {
final initial = !index.started;
final anchor = initial ? await resolveAnchor() : index.items.last.messageId;
if (anchor == null || anchor.isEmpty) {
index.reachedEnd = true;
return;
}
final page = await fetchMedia(
chatId: chatId,
anchorMessageId: anchor,
attachTypes: const ['PHOTO', 'VIDEO'],
forward: initial ? _mediaIndexPageSize : 0,
backward: _mediaIndexPageSize,
);
index.started = true;
if (page.total > index.total) index.total = page.total;
final fresh = <SharedMediaItem>[];
for (final item in page.items) {
if (index.seen.add(item.dedupKey)) fresh.add(item);
}
if (fresh.isEmpty) {
index.reachedEnd = true;
return;
}
final oldest = index.items.isEmpty ? null : index.items.last;
index.items.addAll(fresh);
if (oldest != null && fresh.first.time > oldest.time) {
sortMediaNewestFirst(index.items);
}
}
Future<SharedMediaPage> fetchMedia({
required int chatId,
required String anchorMessageId,
required List<String> attachTypes,
int forward = 0,
int backward = 60,
}) async {
try {
final response = await _api.sendRequest(Opcode.chatMedia, {
'chatId': chatId,
'messageId': int.tryParse(anchorMessageId) ?? 0,
'attachTypes': attachTypes,
'forward': forward,
'backward': backward,
});
if (!response.isOk) return SharedMediaPage.empty;
final data = response.payload;
if (data is! Map) return SharedMediaPage.empty;
final messages = data['messages'];
if (messages is! List) return SharedMediaPage.empty;
final wanted = attachTypes
.map((t) => _attachTypeByName[t])
.whereType<AttachmentType>()
.toSet();
final out = <SharedMediaItem>[];
for (final m in messages) {
if (m is! Map) continue;
final map = Map<String, dynamic>.from(m);
final id = map['id']?.toString();
if (id == null) continue;
final sender = (map['sender'] as num?)?.toInt() ?? 0;
final time = (map['time'] as num?)?.toInt() ?? 0;
final text = map['text'] as String?;
final attaches = map['attaches'];
if (attaches is! List) continue;
for (final a in attaches) {
if (a is! Map) continue;
final att = MessageAttachment.fromMap(Map<String, dynamic>.from(a));
if (!wanted.contains(att.type)) continue;
out.add(
SharedMediaItem(
messageId: id,
chatId: chatId,
senderId: sender,
time: time,
attachment: att,
text: text,
),
);
}
}
sortMediaNewestFirst(out);
final total = (data['total'] as num?)?.toInt() ?? out.length;
return SharedMediaPage(items: out, total: total);
} catch (e) {
logger.w('SharedContent.fetchMedia failed: $e');
return SharedMediaPage.empty;
}
}
Future<List<CommonChatEntry>> fetchCommonChats(int userId) async {
try {
final response = await _api.sendRequest(
Opcode.chatSearchCommonParticipants,
{
'userIds': [userId],
},
);
if (!response.isOk) return const [];
final data = response.payload;
if (data is! Map) return const [];
final chats = data['commonChats'];
if (chats is! List) return const [];
final out = <CommonChatEntry>[];
for (final c in chats) {
if (c is Map) {
out.add(CommonChatEntry.fromMap(Map<String, dynamic>.from(c)));
}
}
return out;
} catch (e) {
logger.w('SharedContent.fetchCommonChats failed: $e');
return const [];
}
}
}
void sortMediaNewestFirst(List<SharedMediaItem> items) {
final ordered = [for (var i = 0; i < items.length; i++) (i, items[i])];
ordered.sort((a, b) {
final byTime = b.$2.time.compareTo(a.$2.time);
return byTime != 0 ? byTime : a.$1.compareTo(b.$1);
});
for (var i = 0; i < ordered.length; i++) {
items[i] = ordered[i].$2;
}
}
+316
View File
@@ -0,0 +1,316 @@
import 'dart:math';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/utils/logger.dart';
import '../../models/sticker.dart';
class StickersModule {
final Api _api;
StickersModule(this._api) {
_api.pushStream
.where((p) => p.opcode == Opcode.notifAssetsUpdate)
.listen(_handleAssetsPush);
}
final Map<int, StickerSet> _sets = {};
final Map<int, StickerItem> _stickers = {};
List<int> _orderedSetIds = [];
List<int> _favoriteSetIds = [];
List<int> _recentStickerIds = [];
Future<void>? _loading;
Future<void>? _favoritesLoading;
final Random _random = Random();
List<StickerSet> get sets =>
_orderedSetIds.map((id) => _sets[id]).whereType<StickerSet>().toList();
List<int> get favoriteSetIds => _favoriteSetIds;
List<int> get recentStickerIds => _recentStickerIds;
StickerItem? cachedSticker(int id) => _stickers[id];
StickerSet? cachedSet(int id) => _sets[id];
bool isFavorite(int setId) => _favoriteSetIds.contains(setId);
Future<void> ensureLoaded() {
return _loading ??= _loadSections().catchError((Object e) {
_loading = null;
throw e;
});
}
Future<void> ensureFavoritesLoaded() {
return _favoritesLoading ??= _loadFavorites().catchError((Object e) {
_favoritesLoading = null;
throw e;
});
}
Future<void> _loadFavorites() async {
final favIds = <int>[];
final fav = await _api.sendRequestMap(Opcode.assetsUpdate, {
'type': 'FAVORITE_STICKER',
'sync': 0,
});
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') {
_appendIntList(favIds, s['stickerSets']);
}
}
}
}
_favoriteSetIds = favIds;
}
Future<void> _loadSections() async {
final newSetIds = <int>[];
int marker = 0;
final stickerData = await _api.sendRequestMap(Opcode.assetsUpdate, {
'type': 'STICKER',
'sync': 0,
});
if (stickerData != null) {
final sections = stickerData['sections'];
if (sections is List) {
for (final s in sections) {
if (s is! Map) continue;
if (s['id'] == 'NEW_STICKER_SETS') {
_appendIntList(newSetIds, s['stickerSets']);
final m = s['marker'];
if (m is int) marker = m;
} else if (s['type'] == 'RECENTS') {
_parseRecents(s['recentsList']);
}
}
}
}
var guard = 0;
while (marker != 0 && guard < 50) {
guard++;
final page = await _api.sendRequestMap(Opcode.assetsGet, {
'sectionId': 'NEW_STICKER_SETS',
'from': marker,
'count': 100,
});
if (page == null) break;
final before = newSetIds.length;
_appendIntList(newSetIds, page['stickerSets']);
if (newSetIds.length == before) break;
final m = page['marker'];
marker = m is int ? m : 0;
}
await ensureFavoritesLoaded();
final ordered = <int>[];
final seen = <int>{};
for (final id in [..._favoriteSetIds, ...newSetIds]) {
if (seen.add(id)) ordered.add(id);
}
_orderedSetIds = ordered;
logger.i(
'Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних',
);
await _ensureSetMetas(ordered);
}
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 map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
'type': type,
'ids': batch,
});
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) {
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 {
await _fetchAndCache<StickerItem>(
type: 'STICKER',
ids: stickerIds,
listKey: 'stickers',
fromMap: StickerItem.fromMap,
cache: _stickers,
);
return stickerIds
.map((id) => _stickers[id])
.whereType<StickerItem>()
.toList();
}
Future<StickerSet?> ensureSet(int setId) async {
await _ensureSetMetas([setId]);
return _sets[setId];
}
Future<void> ensureAllStickersLoaded() {
final ids = <int>{..._recentStickerIds};
for (final set in _sets.values) {
ids.addAll(set.stickerIds);
}
return ensureStickers(ids.toList());
}
List<StickerItem> searchByTags(Set<String> emojiTargets) {
if (emojiTargets.isEmpty) return const [];
final result = <StickerItem>[];
for (final sticker in _stickers.values) {
for (final tag in sticker.tags) {
if (emojiTargets.contains(_stripVariation(tag))) {
result.add(sticker);
break;
}
}
}
return result;
}
static String _stripVariation(String s) => s.replaceAll('️', '');
static const String greetingEmoji = '👋';
static const int greetingSetScanLimit = 5;
Future<StickerItem?> randomGreetingSticker(List<int> welcomeIds) async {
if (welcomeIds.isNotEmpty) {
final welcome = await ensureStickers(welcomeIds);
final animated = welcome.where((s) => s.isAnimated).toList();
final pool = animated.isNotEmpty ? animated : welcome;
if (pool.isNotEmpty) return pool[_random.nextInt(pool.length)];
}
final cached = _animatedGreetings();
if (cached.isNotEmpty) return cached[_random.nextInt(cached.length)];
await ensureLoaded();
final shuffled = List<StickerSet>.of(sets)..shuffle(_random);
for (final set in shuffled.take(greetingSetScanLimit)) {
await ensureStickers(set.stickerIds);
final found = _animatedGreetings();
if (found.isNotEmpty) return found[_random.nextInt(found.length)];
}
return null;
}
List<StickerItem> _animatedGreetings() => [
for (final sticker in _stickers.values)
if (sticker.isAnimated &&
sticker.tags.any((tag) => tag.contains(greetingEmoji)))
sticker,
];
void cacheSet(StickerSet set) => _sets[set.id] = set;
Future<StickerSet?> resolveSetByLink(String link) async {
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;
return set;
}
Future<int?> resolveSetId(int stickerId) async {
await ensureStickers([stickerId]);
return _stickers[stickerId]?.setId;
}
Future<bool> favoriteSet(int setId) async {
final map = await _api.sendRequestMap(Opcode.assetsAdd, {
'type': 'FAVORITE_STICKER_SET',
'id': setId,
});
final ok = map != null && map['success'] == true;
if (ok) _markFavorite(setId, true);
return ok;
}
Future<bool> unfavoriteSet(int setId) async {
final map = await _api.sendRequestMap(Opcode.assetsRemove, {
'type': 'FAVORITE_STICKER_SET',
'ids': [setId],
});
final ok = map != null && map['success'] == true;
if (ok) _markFavorite(setId, false);
return ok;
}
void _handleAssetsPush(Packet push) {
final payload = push.payload;
if (payload is! Map) return;
if (payload['type'] != 'FAVORITE_STICKER_SET') return;
final id = payload['id'];
if (id is! int) return;
switch (payload['updateType']) {
case 'ADDED':
_markFavorite(id, true);
case 'REMOVED':
_markFavorite(id, false);
}
}
void _markFavorite(int setId, bool favorite) {
if (favorite) {
if (!_favoriteSetIds.contains(setId)) {
_favoriteSetIds = [setId, ..._favoriteSetIds];
}
} else {
if (_favoriteSetIds.contains(setId)) {
_favoriteSetIds = _favoriteSetIds.where((id) => id != setId).toList();
}
}
}
void _parseRecents(dynamic list) {
if (list is! List) return;
final ids = <int>[];
for (final e in list) {
if (e is Map && e['type'] == 'STICKER') {
final sid = e['stickerId'] ?? e['id'];
if (sid is int) ids.add(sid);
}
}
_recentStickerIds = ids;
}
void _appendIntList(List<int> target, dynamic raw) {
if (raw is! List) return;
for (final e in raw) {
if (e is int) target.add(e);
}
}
Iterable<List<T>> _chunk<T>(List<T> list, int size) sync* {
for (var i = 0; i < list.length; i += size) {
yield list.sublist(i, i + size > list.length ? list.length : i + size);
}
}
}
+553
View File
@@ -0,0 +1,553 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.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 '../../core/utils/logger.dart';
import '../../models/story.dart';
import '../api.dart';
/// Работа с «Историями»: лента-кольца, полные истории владельца, отметка
/// просмотра и реакции. Кэшируется в SQLite (превью, полные истории и позиция
/// просмотра) — переживает перезапуск; истёкшие кольца отсеиваются при загрузке.
class StoriesModule {
StoriesModule(this._api);
static const _previewsKey = 'stories_previews';
static const _peersKey = 'stories_peers';
static const _progressKey = 'stories_progress';
final Api _api;
final Map<int, StoryPreview> _previews = {};
final Map<int, List<Story>> _peerStories = {};
/// ownerId → storyId, на котором пользователь остановил просмотр.
final Map<int, int> _lastViewed = {};
int? _accountId;
StreamSubscription<Packet>? _pushSub;
/// Бампается при любом изменении лент/историй — UI слушает и перечитывает.
final ValueNotifier<int> storiesChanged = ValueNotifier<int>(0);
void _bump() => storiesChanged.value++;
Future<int?> _acc() async {
_accountId ??= await TokenStorage.getActiveAccountId();
return _accountId;
}
int _nowMs() => DateTime.now().millisecondsSinceEpoch;
int _normMs(int t) => t <= 0 ? 0 : (t < 1000000000000 ? t * 1000 : t);
// ── Кэш (SQLite) ───────────────────────────────────────────────────────
/// Загружает кэш из БД (превью/истории/позиции) и показывает мгновенно,
/// до сетевого ответа. Истёкшие кольца отбрасываются.
Future<void> loadCache() async {
final acc = await _acc();
if (acc == null) return;
try {
final rawPreviews = await AppDatabase.getSyncValue(acc, _previewsKey);
if (rawPreviews != null && rawPreviews.isNotEmpty) {
final list = jsonDecode(rawPreviews);
final now = _nowMs();
if (list is List) {
for (final raw in list) {
final preview = StoryPreview.fromMap(raw);
if (preview == null || preview.isEmpty) continue;
final exp = _normMs(preview.lastStoryExpirationTime);
if (exp != 0 && exp < now) continue;
// Не затираем уже загруженные из сети (более свежие) кольца.
_previews.putIfAbsent(preview.owner.ownerId, () => preview);
}
}
}
final rawPeers = await AppDatabase.getSyncValue(acc, _peersKey);
if (rawPeers != null && rawPeers.isNotEmpty) {
final map = jsonDecode(rawPeers);
if (map is Map) {
map.forEach((key, value) {
final ownerId = int.tryParse(key.toString());
if (ownerId == null || value is! List) return;
if (!_previews.containsKey(ownerId)) return;
if (_peerStories.containsKey(ownerId)) return;
final stories = <Story>[];
for (final s in value) {
final story = Story.fromMap(s);
if (story != null) stories.add(story);
}
if (stories.isNotEmpty) _peerStories[ownerId] = stories;
});
}
}
final rawProgress = await AppDatabase.getSyncValue(acc, _progressKey);
if (rawProgress != null && rawProgress.isNotEmpty) {
final map = jsonDecode(rawProgress);
if (map is Map) {
map.forEach((key, value) {
final ownerId = int.tryParse(key.toString());
final storyId = value is int ? value : int.tryParse('$value');
if (ownerId != null && storyId != null) {
_lastViewed.putIfAbsent(ownerId, () => storyId);
}
});
}
}
_bump();
} catch (e) {
logger.w('StoriesModule.loadCache: $e');
}
}
Future<void> _persistPreviews() async {
final acc = await _acc();
if (acc == null) return;
final list = _previews.values.map((p) => p.toJson()).toList();
await AppDatabase.setSyncValue(acc, _previewsKey, jsonEncode(list));
}
Future<void> _persistPeers() async {
final acc = await _acc();
if (acc == null) return;
final map = <String, dynamic>{};
_peerStories.forEach((ownerId, stories) {
if (!_previews.containsKey(ownerId)) return;
map['$ownerId'] = stories.map((s) => s.toJson()).toList();
});
await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map));
}
Future<void> _persistProgress() async {
final acc = await _acc();
if (acc == null) return;
final map = <String, int>{};
_lastViewed.forEach((ownerId, storyId) => map['$ownerId'] = storyId);
await AppDatabase.setSyncValue(acc, _progressKey, jsonEncode(map));
}
// ── Позиция просмотра ──────────────────────────────────────────────────
/// Запоминает, что у [ownerId] пользователь остановился на [storyId].
void setLastViewed(int ownerId, int storyId) {
if (storyId == 0 || _lastViewed[ownerId] == storyId) return;
_lastViewed[ownerId] = storyId;
unawaited(_persistProgress());
}
int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId];
void clearLastViewed(int ownerId) {
if (_lastViewed.remove(ownerId) == null) return;
unawaited(_persistProgress());
}
/// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени.
List<StoryPreview> get previews {
final list = _previews.values.where((p) => !p.isEmpty).toList();
list.sort((a, b) {
if (a.hasUnread != b.hasUnread) return a.hasUnread ? -1 : 1;
return b.updateTime.compareTo(a.updateTime);
});
return list;
}
bool get hasAny => previews.isNotEmpty;
StoryPreview? previewFor(int ownerId) => _previews[ownerId];
final Map<int, StoryPreview> _peerPreviews = {};
final Set<int> _requestedOwners = {};
static const int _ownersChunk = 20;
StoryPreview? previewOf(int ownerId) {
final feed = _previews[ownerId];
if (feed != null) return feed.isEmpty ? null : feed;
final peer = _peerPreviews[ownerId];
return (peer == null || peer.isEmpty) ? null : peer;
}
Future<void> loadOwnersPreviews(List<int> ownerIds) async {
if (_api.state != SessionState.online) return;
final missing = ownerIds
.where((id) => id > 0 && !_requestedOwners.contains(id))
.toSet()
.toList();
if (missing.isEmpty) return;
_requestedOwners.addAll(missing);
var changed = false;
for (var i = 0; i < missing.length; i += _ownersChunk) {
final end = i + _ownersChunk > missing.length
? missing.length
: i + _ownersChunk;
final chunk = missing.sublist(i, end);
try {
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
'owners': [for (final id in chunk) StoryOwner(ownerId: id).toMap()],
}, silent: true);
if (packet.isError) continue;
if (_applyOwnerPayload(packet.payload, chunk)) changed = true;
} catch (e) {
logger.w('StoriesModule.loadOwnersPreviews: $e');
}
}
if (changed) _bump();
}
bool _applyOwnerPayload(Object? data, List<int> requested) {
if (data is! Map) return false;
var changed = false;
final seen = <int>{};
final rawPreviews = data['storiesPreviews'];
if (rawPreviews is List) {
for (final raw in rawPreviews) {
final preview = StoryPreview.fromMap(raw);
if (preview == null) continue;
final id = preview.owner.ownerId;
seen.add(id);
if (preview.isEmpty) {
_peerPreviews.remove(id);
} else {
_peerPreviews[id] = preview;
}
_refreshPreview(preview);
changed = true;
}
}
for (final id in requested) {
if (!seen.contains(id) && _peerPreviews.remove(id) != null) {
changed = true;
}
}
final rawPeers = data['peerStories'];
if (rawPeers is List) {
for (final raw in rawPeers) {
final peer = PeerStories.fromMap(raw);
if (peer == null) continue;
_peerStories[peer.owner.ownerId] = peer.stories;
changed = true;
}
}
return changed;
}
List<Story>? cachedStories(int ownerId) => _peerStories[ownerId];
/// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE).
void attach() {
_pushSub ??= _api.pushStream
.where((p) => p.opcode == Opcode.notifStoriesUpdate)
.listen(_onPush);
}
void _onPush(Packet packet) {
final payload = packet.payload;
if (payload is! Map) return;
final preview = StoryPreview.fromMap(payload['storiesPreview']);
if (preview == null) return;
_applyPreview(preview);
_bump();
unawaited(_persistPreviews());
}
void _refreshPreview(StoryPreview preview) {
if (!_previews.containsKey(preview.owner.ownerId)) return;
_applyPreview(preview);
}
void _applyPreview(StoryPreview preview) {
if (preview.isEmpty) {
_previews.remove(preview.owner.ownerId);
_peerStories.remove(preview.owner.ownerId);
} else {
_previews[preview.owner.ownerId] = preview;
}
}
/// Первая страница ленты историй. Возвращает false при ошибке/оффлайне.
Future<bool> loadFeed({int count = 20}) async {
if (_api.state != SessionState.online) return false;
try {
final packet = await _api.sendRequest(Opcode.storiesList, {
'cursor': '',
'count': count,
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return false;
final rawPreviews = data['storiesPreviews'];
if (rawPreviews is List) {
_previews.clear();
for (final raw in rawPreviews) {
final preview = StoryPreview.fromMap(raw);
if (preview != null) _applyPreview(preview);
}
}
_bump();
unawaited(_persistPreviews());
return true;
} catch (e) {
logger.w('StoriesModule.loadFeed: $e');
return false;
}
}
/// Полные истории владельца. Обновляет кэш и кольцо, возвращает список.
Future<List<Story>> getByOwner(StoryOwner owner) async {
if (_api.state != SessionState.online) {
return _peerStories[owner.ownerId] ?? const [];
}
try {
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
'owners': [owner.toMap()],
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return _peerStories[owner.ownerId] ?? const [];
final rawPreviews = data['storiesPreviews'];
if (rawPreviews is List) {
for (final raw in rawPreviews) {
final preview = StoryPreview.fromMap(raw);
if (preview != null) _refreshPreview(preview);
}
}
final rawPeers = data['peerStories'];
List<Story> result = const [];
if (rawPeers is List) {
for (final raw in rawPeers) {
final peer = PeerStories.fromMap(raw);
if (peer == null) continue;
_peerStories[peer.owner.ownerId] = peer.stories;
if (peer.owner.ownerId == owner.ownerId) result = peer.stories;
}
}
_bump();
unawaited(_persistPreviews());
unawaited(_persistPeers());
return result;
} catch (e) {
logger.w('StoriesModule.getByOwner: $e');
return _peerStories[owner.ownerId] ?? const [];
}
}
Future<StoryPreview?> loadOwnerPreview(StoryOwner owner) async {
final cached = _previews[owner.ownerId];
if (_api.state != SessionState.online) return cached;
try {
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
'owners': [owner.toMap()],
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return cached;
_applyOwnerPayload(data, [owner.ownerId]);
_requestedOwners.add(owner.ownerId);
final own = previewOf(owner.ownerId);
_bump();
unawaited(_persistPreviews());
unawaited(_persistPeers());
return own;
} catch (e) {
logger.w('StoriesModule.loadOwnerPreview: $e');
return cached;
}
}
/// Отметить историю просмотренной. Оптимистично поднимает readCount кольца.
Future<bool> mark(StoryOwner owner, int storyId) async {
if (_api.state != SessionState.online) return false;
try {
final ok = await _api.sendRequestOk(Opcode.storiesMark, {
'owner': owner.toMap(),
'storyId': storyId,
});
if (ok) _markReadLocally(owner.ownerId);
return ok;
} catch (e) {
logger.w('StoriesModule.mark: $e');
return false;
}
}
void _markReadLocally(int ownerId) {
final preview = _previews[ownerId];
if (preview == null) return;
if (preview.readCount >= preview.totalCount) return;
_previews[ownerId] = preview.copyWith(readCount: preview.readCount + 1);
_bump();
unawaited(_persistPreviews());
}
/// Публикация фото-истории. [photoToken] — токен уже загруженного фото.
/// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс.
/// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI
/// показал реальную причину, а не общее «не удалось».
Future<void> publishPhoto({
required String photoToken,
int settings = 1,
int expiration = 86400000,
}) {
return _publishMedia(
media: {'_type': 'PHOTO', 'photoToken': photoToken},
settings: settings,
expiration: expiration,
);
}
/// Публикация видео-истории. [videoToken] — токен уже загруженного видео
/// (`VideoUploadInfo.token`), [durationMs] — длительность ролика в мс.
Future<void> publishVideo({
required String videoToken,
int? durationMs,
int settings = 1,
int expiration = 86400000,
}) {
return _publishMedia(
media: {
'_type': 'VIDEO',
'videoType': 2,
'token': videoToken,
'duration': ?durationMs,
},
settings: settings,
expiration: expiration,
);
}
Future<void> _publishMedia({
required Map<String, dynamic> media,
required int settings,
required int expiration,
}) async {
if (_api.state != SessionState.online) {
throw const PacketError('Нет соединения с сервером');
}
final cid = DateTime.now().millisecondsSinceEpoch;
final packet = await _api.sendRequest(Opcode.storiesSend, {
'stories': [
{
'cid': cid,
'settings': settings,
'media': media,
'expiration': expiration,
},
],
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is Map) {
final preview = StoryPreview.fromMap(data['storiesPreview']);
if (preview != null) _applyPreview(preview);
final rawStories = data['stories'];
if (rawStories is List) {
for (final raw in rawStories) {
final story = Story.fromMap(raw);
if (story == null) continue;
final list = _peerStories.putIfAbsent(
story.owner.ownerId,
() => <Story>[],
);
list.add(story);
}
}
_bump();
unawaited(_persistPreviews());
unawaited(_persistPeers());
}
}
// #***! удаление своих историй: storyIds списком, как шлёт веб
static Map<String, dynamic> deletePayload(List<int> storyIds) => {
'storyIds': storyIds,
};
/// Deletes the user's own stories and drops them from the cache.
/// Throws [PacketError]/[TimeoutException] so the UI can show the reason.
Future<void> deleteStories(StoryOwner owner, List<int> storyIds) async {
if (storyIds.isEmpty) return;
if (_api.state != SessionState.online) {
throw const PacketError('Нет соединения с сервером');
}
final packet = await _api.sendRequest(
Opcode.storiesDelete,
deletePayload(storyIds),
);
throwIfPacketError(packet);
removeStoriesLocally(owner.ownerId, storyIds);
}
// #***! убираем истории из кэша, кольцо пересчитываем, пустое выкидываем
void removeStoriesLocally(int ownerId, List<int> storyIds) {
final ids = storyIds.toSet();
final stories = _peerStories[ownerId];
var removed = ids.length;
if (stories != null) {
final before = stories.length;
final left = stories.where((s) => !ids.contains(s.id)).toList();
removed = before - left.length;
if (left.isEmpty) {
_peerStories.remove(ownerId);
} else {
_peerStories[ownerId] = left;
}
}
StoryPreview? shrink(StoryPreview? preview) {
if (preview == null) return null;
final total = math.max(0, preview.totalCount - removed);
return preview.copyWith(
totalCount: total,
readCount: math.min(preview.readCount, total),
);
}
final feed = shrink(_previews[ownerId]);
if (feed != null) _applyPreview(feed);
final peer = shrink(_peerPreviews[ownerId]);
if (peer != null) {
if (peer.isEmpty) {
_peerPreviews.remove(ownerId);
} else {
_peerPreviews[ownerId] = peer;
}
}
final lastViewed = _lastViewed[ownerId];
if (lastViewed != null && ids.contains(lastViewed)) {
clearLastViewed(ownerId);
}
_bump();
unawaited(_persistPreviews());
unawaited(_persistPeers());
}
// #***! сброс при смене аккаунта
void clear() {
_previews.clear();
_peerStories.clear();
_lastViewed.clear();
_accountId = null;
_bump();
}
void dispose() {
_pushSub?.cancel();
_pushSub = null;
storiesChanged.dispose();
}
}
@@ -0,0 +1,209 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart';
import '../../main.dart' show QlyraApp;
enum UploadKind { photo, video, videoNote, voice, file }
class _NotificationJob {
_NotificationJob({required this.kind, required this.count, this.filename});
final UploadKind kind;
final int count;
final String? filename;
int sent = 0;
int total = 0;
double fraction = 0;
int speedBps = 0;
int _windowSent = 0;
int _windowAt = DateTime.now().millisecondsSinceEpoch;
void report(int sentBytes, int totalBytes, double jobFraction) {
sent = sentBytes;
total = totalBytes;
fraction = jobFraction.clamp(0.0, 1.0);
final now = DateTime.now().millisecondsSinceEpoch;
final elapsed = now - _windowAt;
if (elapsed < 500) return;
final delta = sent - _windowSent;
speedBps = delta <= 0 ? 0 : (delta * 1000 / elapsed).round();
_windowSent = sent;
_windowAt = now;
}
String label(AppLocalizations l10n) {
final name = filename;
return switch (kind) {
UploadKind.photo => l10n.uploadNotificationPhotos(count),
UploadKind.video => l10n.uploadNotificationVideo,
UploadKind.videoNote => l10n.uploadNotificationVideoNote,
UploadKind.voice => l10n.uploadNotificationVoice,
UploadKind.file =>
name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
};
}
}
class UploadNotificationService {
static const MethodChannel _channel = MethodChannel(
'ru.ikar.app/upload_service',
);
static const int _minIntervalMs = 350;
static const Duration _startDelay = Duration(milliseconds: 700);
static final Map<String, _NotificationJob> _jobs = {};
static Timer? _startTimer;
static bool _running = false;
static String? _lastTitle;
static String? _lastBody;
static int _lastPercent = -1;
static int _lastPushAt = 0;
static bool get _enabled =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
static void begin(
String id, {
required UploadKind kind,
int count = 1,
String? filename,
}) {
if (!_enabled) return;
_jobs[id] = _NotificationJob(
kind: kind,
count: count < 1 ? 1 : count,
filename: filename,
);
if (_running) {
_push(force: true);
return;
}
_startTimer ??= Timer(_startDelay, () {
_startTimer = null;
_push(force: true);
});
}
static void report(
String id, {
required int sent,
required int total,
required double fraction,
}) {
if (!_enabled) return;
final job = _jobs[id];
if (job == null) return;
job.report(sent, total, fraction);
_push();
}
static void end(String id) {
if (!_enabled) return;
if (_jobs.remove(id) == null) return;
if (_jobs.isEmpty) {
_stop();
return;
}
_push(force: true);
}
static void _stop() {
_startTimer?.cancel();
_startTimer = null;
final wasRunning = _running;
_running = false;
_lastTitle = null;
_lastBody = null;
_lastPercent = -1;
_lastPushAt = 0;
if (wasRunning) _invoke('stop', const <String, dynamic>{});
}
static void _push({bool force = false}) {
if (_jobs.isEmpty) return;
if (!_running && _startTimer != null) return;
var sumSent = 0;
var sumTotal = 0;
var sumSpeed = 0;
var fractionSum = 0.0;
var sizesKnown = true;
for (final job in _jobs.values) {
sumSent += job.sent;
sumTotal += job.total;
sumSpeed += job.speedBps;
fractionSum += job.fraction;
if (job.total <= 0) sizesKnown = false;
}
final fraction = sizesKnown && sumTotal > 0
? sumSent / sumTotal
: fractionSum / _jobs.length;
final percent = (fraction * 100).round().clamp(0, 100);
final l10n = _localizations();
final title = _jobs.length == 1
? _jobs.values.first.label(l10n)
: l10n.uploadNotificationMultiple(_jobs.length);
final body = percent <= 0 && sumSpeed <= 0
? l10n.uploadNotificationPreparing
: sumSpeed > 0
? '$percent% · ${_formatSpeed(l10n, sumSpeed)}'
: '$percent%';
final now = DateTime.now().millisecondsSinceEpoch;
final changed =
title != _lastTitle || body != _lastBody || percent != _lastPercent;
if (!force && (!changed || now - _lastPushAt < _minIntervalMs)) return;
_lastTitle = title;
_lastBody = body;
_lastPercent = percent;
_lastPushAt = now;
final args = <String, dynamic>{
'title': title,
'body': body,
'progress': percent,
'indeterminate': percent <= 0 && sumSpeed <= 0,
};
if (_running) {
_invoke('update', args);
return;
}
_running = true;
_invoke('start', args);
}
static String _formatSpeed(AppLocalizations l10n, int bps) {
if (bps < 1024) return l10n.uploadSpeedBytes('$bps');
if (bps < 1024 * 1024) return l10n.uploadSpeedKb('${(bps / 1024).round()}');
return l10n.uploadSpeedMb((bps / (1024 * 1024)).toStringAsFixed(1));
}
static AppLocalizations _localizations() {
final context = QlyraApp.navigatorKey.currentContext;
if (context != null) {
final scoped = Localizations.of<AppLocalizations>(
context,
AppLocalizations,
);
if (scoped != null) return scoped;
}
final code = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
return lookupAppLocalizations(Locale(code == 'ru' ? 'ru' : 'en'));
}
static Future<void> _invoke(String method, Map<String, dynamic> args) async {
try {
await _channel.invokeMethod(method, args);
} catch (_) {}
}
}
+714
View File
@@ -0,0 +1,714 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../../core/cache/message_session_cache.dart';
import '../../core/media/gallery_source.dart';
import '../../core/media/image_optimizer.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../main.dart' show fileUploader, messagesModule;
import '../../models/attachment.dart';
import 'chats.dart' show chats;
import 'file_uploader.dart';
import 'messages.dart';
import 'upload_notification_service.dart';
export 'upload_notification_service.dart' show UploadKind;
sealed class UploadJobEvent {
const UploadJobEvent({
required this.chatId,
required this.tempId,
required this.kind,
required this.scheduled,
});
final int chatId;
final String tempId;
final UploadKind kind;
final bool scheduled;
}
class UploadJobDone extends UploadJobEvent {
const UploadJobDone({
required super.chatId,
required super.tempId,
required super.kind,
required super.scheduled,
this.message,
this.scheduledTime,
this.fileId,
this.fileToken,
});
final CachedMessage? message;
final int? scheduledTime;
final int? fileId;
final String? fileToken;
}
class UploadJobFailed extends UploadJobEvent {
const UploadJobFailed({
required super.chatId,
required super.tempId,
required super.kind,
required super.scheduled,
required this.reason,
});
final String reason;
}
class UploadFailure implements Exception {
const UploadFailure(this.message);
final String message;
@override
String toString() => 'UploadFailure($message)';
}
class UploadBytes {
const UploadBytes(this.sent, this.total);
final int sent;
final int total;
}
class UploadJob {
UploadJob._({
required this.id,
required this.accountId,
required this.chatId,
required this.kind,
required int slots,
this.filename,
this.totalBytes = 0,
this.placeholder,
this.scheduledTime,
}) : _slots = slots < 1 ? 1 : slots,
_sent = List<int>.filled(slots < 1 ? 1 : slots, 0),
_total = List<int>.filled(slots < 1 ? 1 : slots, 0),
progress = ValueNotifier<List<double>>(
List<double>.filled(slots < 1 ? 1 : slots, 0),
),
bytes = ValueNotifier<UploadBytes>(UploadBytes(0, totalBytes)) {
if (_slots == 1 && totalBytes > 0) _total[0] = totalBytes;
}
final String id;
final int accountId;
final int chatId;
final UploadKind kind;
final String? filename;
final int totalBytes;
final CachedMessage? placeholder;
final int? scheduledTime;
final ValueNotifier<List<double>> progress;
final ValueNotifier<UploadBytes> bytes;
int? resultFileId;
String? resultFileToken;
final int _slots;
final List<int> _sent;
final List<int> _total;
bool get scheduled => scheduledTime != null;
int get slots => _slots;
void report(int slot, int sent, int total) {
if (slot < 0 || slot >= _slots) return;
_sent[slot] = sent;
if (total > 0) _total[slot] = total;
_publish();
}
void resetSlot(int slot) {
if (slot < 0 || slot >= _slots) return;
_sent[slot] = 0;
_publish();
}
void markUploaded() {
for (var i = 0; i < _slots; i++) {
if (_total[i] <= 0) _total[i] = _sent[i] > 0 ? _sent[i] : 1;
_sent[i] = _total[i];
}
_publish();
}
void _publish() {
final fractions = List<double>.generate(_slots, (i) {
if (_total[i] <= 0) return 0.0;
return (_sent[i] / _total[i]).clamp(0.0, 1.0);
});
var sumSent = 0;
var sumTotal = 0;
var fractionSum = 0.0;
for (var i = 0; i < _slots; i++) {
sumSent += _sent[i];
sumTotal += _total[i];
fractionSum += fractions[i];
}
progress.value = fractions;
bytes.value = UploadBytes(sumSent, sumTotal);
UploadNotificationService.report(
id,
sent: sumSent,
total: sumTotal,
fraction: fractionSum / _slots,
);
}
}
class UploadService {
UploadService._();
static final UploadService instance = UploadService._();
static const int _historyLimit = 60;
static const int _photoConcurrency = 3;
static const int _photoAttempts = 3;
final StreamController<UploadJobEvent> _events =
StreamController<UploadJobEvent>.broadcast();
final Map<String, UploadJob> _jobs = {};
final Map<String, CachedMessage> _completed = {};
final Set<String> _failed = {};
int _tempIdCounter = 0;
Stream<UploadJobEvent> get events => _events.stream;
String newTempId() =>
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
UploadJob? job(String tempId) => _jobs[tempId];
ValueListenable<List<double>>? progressFor(String tempId) =>
_jobs[tempId]?.progress;
UploadJob? activeFileJob(int chatId) {
for (final job in _jobs.values) {
if (job.chatId == chatId && job.kind == UploadKind.file) return job;
}
return null;
}
List<CachedMessage> pendingFor(int chatId) {
final pending = <CachedMessage>[];
for (final job in _jobs.values) {
final placeholder = job.placeholder;
if (job.chatId == chatId && placeholder != null) pending.add(placeholder);
}
return List<CachedMessage>.unmodifiable(pending);
}
CachedMessage? completedFor(String tempId) => _completed[tempId];
bool didFail(String tempId) => _failed.contains(tempId);
Future<void> sendPhotos({
required int accountId,
required int chatId,
required String tempId,
required List<({File file, GalleryItem? item})> jobs,
required String caption,
CachedMessage? placeholder,
int? scheduledTime,
}) {
return _run(
UploadJob._(
id: tempId,
accountId: accountId,
chatId: chatId,
kind: UploadKind.photo,
slots: jobs.length,
placeholder: placeholder,
scheduledTime: scheduledTime,
),
(job) async {
final tokens = await _uploadPhotos(jobs, job);
if (tokens.any((token) => token == null)) {
throw const UploadFailure('upload_failed');
}
final sent = await messagesModule.sendPhotoMessage(
chatId,
tokens.cast<String>(),
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
if (sent == null) return null;
return CachedMessage.fromPushPayload(accountId, chatId, sent);
},
);
}
Future<void> sendVideo({
required int accountId,
required int chatId,
required String tempId,
required File file,
required String caption,
CachedMessage? placeholder,
int? scheduledTime,
}) {
return _run(
UploadJob._(
id: tempId,
accountId: accountId,
chatId: chatId,
kind: UploadKind.video,
slots: 1,
placeholder: placeholder,
scheduledTime: scheduledTime,
),
(job) async {
final info = await messagesModule.requestVideoUploadUrl();
if (info == null || info.url.isEmpty) {
throw const UploadFailure('no_upload_url');
}
final ok = await fileUploader.uploadVideoFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) => job.report(0, sent, total),
);
if (!ok) throw const UploadFailure('upload_failed');
job.markUploaded();
final sent = await messagesModule.sendVideoMessage(
chatId,
info.token,
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
if (sent == null) return null;
return CachedMessage.fromPushPayload(accountId, chatId, sent);
},
);
}
Future<void> sendVoice({
required int accountId,
required int chatId,
required String tempId,
required File file,
required int durationMs,
required Uint8List wave,
CachedMessage? placeholder,
}) {
return _sendRecording(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: file,
kind: UploadKind.voice,
placeholder: placeholder,
requestUpload: () async {
final info = await messagesModule.requestAudioUploadUrl();
return info == null ? null : (url: info.url, token: info.token);
},
send: (token) => messagesModule.sendAudioMessage(
chatId,
token,
duration: durationMs,
wave: wave,
),
);
}
Future<void> sendVideoNote({
required int accountId,
required int chatId,
required String tempId,
required File file,
required int durationMs,
CachedMessage? placeholder,
}) {
return _sendRecording(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: file,
kind: UploadKind.videoNote,
placeholder: placeholder,
requestUpload: () async {
final info = await messagesModule.requestVideoNoteUploadUrl();
return info == null ? null : (url: info.url, token: info.token);
},
send: (token) => messagesModule.sendVideoNoteMessage(
chatId,
token,
duration: durationMs,
),
);
}
Future<void> _sendRecording({
required int accountId,
required int chatId,
required String tempId,
required File file,
required UploadKind kind,
required Future<({String url, String token})?> Function() requestUpload,
required Future<Map<String, dynamic>?> Function(String token) send,
CachedMessage? placeholder,
}) {
return _run(
UploadJob._(
id: tempId,
accountId: accountId,
chatId: chatId,
kind: kind,
slots: 1,
placeholder: placeholder,
),
(job) async {
try {
final info = await requestUpload();
if (info == null || info.url.isEmpty) {
throw const UploadFailure('no_upload_url');
}
final ok = await fileUploader.uploadMediaFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) => job.report(0, sent, total),
);
if (!ok) throw const UploadFailure('upload_failed');
job.markUploaded();
final sent = await send(info.token);
if (sent == null) return null;
return CachedMessage.fromPushPayload(accountId, chatId, sent);
} finally {
try {
await file.delete();
} catch (_) {}
}
},
);
}
Future<void> sendFile({
required int accountId,
required int chatId,
required String tempId,
required File source,
required String filename,
required int size,
CachedMessage? placeholder,
String? text,
int? scheduledTime,
}) {
return _run(
UploadJob._(
id: tempId,
accountId: accountId,
chatId: chatId,
kind: UploadKind.file,
slots: 1,
filename: filename,
totalBytes: size,
placeholder: placeholder,
scheduledTime: scheduledTime,
),
(job) async {
final done = await _uploadFile(
chatId: chatId,
job: job,
source: source,
filename: filename,
size: size,
text: text,
scheduledTime: scheduledTime,
);
FileHistoryCache.add(
FileHistoryEntry(
fileId: done.fileId,
url: done.url,
token: done.token,
filename: done.filename,
size: done.size,
sentAt: DateTime.now(),
),
);
job.resultFileId = done.fileId;
job.resultFileToken = done.token;
if (scheduledTime != null) return null;
final base = placeholder;
return CachedMessage(
id: done.messageId == null || done.messageId!.isEmpty
? tempId
: done.messageId!,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: base?.text,
time: base?.time ?? DateTime.now().millisecondsSinceEpoch,
status: 'sent',
attachments: [
FileAttachment(
fileId: done.fileId,
fileToken: done.token,
name: filename,
size: size,
),
],
sealedText: base?.sealedText,
e2ee: base?.e2ee ?? CachedMessage.e2eeNone,
);
},
);
}
Future<UploadDone> _uploadFile({
required int chatId,
required UploadJob job,
required File source,
required String filename,
required int size,
String? text,
int? scheduledTime,
}) async {
final result = Completer<UploadDone>();
late final StreamSubscription<UploadEvent> sub;
sub = fileUploader
.upload(
chatId: chatId,
file: source,
filename: filename,
totalSize: size,
text: text,
scheduledTime: scheduledTime,
)
.listen(
(event) {
switch (event) {
case UploadProgress(:final sent, :final total):
job.report(0, sent, total);
case UploadDone():
if (!result.isCompleted) result.complete(event);
case UploadError(:final message):
if (!result.isCompleted) {
result.completeError(UploadFailure(message));
}
}
},
onError: (Object e) {
if (!result.isCompleted) result.completeError(e);
},
onDone: () {
if (!result.isCompleted) {
result.completeError(const UploadFailure('upload_failed'));
}
},
);
try {
return await result.future;
} finally {
await sub.cancel();
}
}
Future<void> _run(
UploadJob job,
Future<CachedMessage?> Function(UploadJob job) upload,
) async {
_jobs[job.id] = job;
UploadNotificationService.begin(
job.id,
kind: job.kind,
count: job.slots,
filename: job.filename,
);
CachedMessage? real;
String? failure;
try {
real = await upload(job);
if (real == null && !job.scheduled) failure = 'send_failed';
} catch (e) {
failure = e is UploadFailure ? e.message : e.toString();
}
UploadNotificationService.end(job.id);
_jobs.remove(job.id);
if (failure != null) {
logger.w('UploadService: ${job.id} — $failure');
if (!job.scheduled) {
_replaceInSessionCache(job.accountId, job.chatId, job.id, null);
_remember(job.id, null);
_syncChatPreview(job.placeholder, 'error');
}
_events.add(
UploadJobFailed(
chatId: job.chatId,
tempId: job.id,
kind: job.kind,
scheduled: job.scheduled,
reason: failure,
),
);
return;
}
if (job.scheduled) {
_events.add(
UploadJobDone(
chatId: job.chatId,
tempId: job.id,
kind: job.kind,
scheduled: true,
scheduledTime: job.scheduledTime,
fileId: job.resultFileId,
fileToken: job.resultFileToken,
),
);
return;
}
final message = real!;
try {
await AppDatabase.saveMessages([message.toDbRow()]);
if (message.id != job.id) {
await AppDatabase.deleteMessage(job.accountId, job.chatId, job.id);
}
} catch (e) {
logger.w('UploadService: не удалось сохранить сообщение: $e');
}
_replaceInSessionCache(job.accountId, job.chatId, job.id, message);
_remember(job.id, message);
_syncChatPreview(message, 'sent', replacesTime: job.placeholder?.time);
_events.add(
UploadJobDone(
chatId: job.chatId,
tempId: job.id,
kind: job.kind,
scheduled: false,
message: message,
fileId: job.resultFileId,
fileToken: job.resultFileToken,
),
);
}
void _syncChatPreview(
CachedMessage? message,
String status, {
int? replacesTime,
}) {
if (message == null) return;
unawaited(
chats.applyOutgoingMessage(
message,
status: status,
replacesTime: replacesTime,
),
);
}
void _remember(String tempId, CachedMessage? real) {
if (_completed.length + _failed.length > _historyLimit) {
_completed.clear();
_failed.clear();
}
if (real == null) {
_failed.add(tempId);
} else {
_completed[tempId] = real;
}
}
Future<List<String?>> _uploadPhotos(
List<({File file, GalleryItem? item})> jobs,
UploadJob job,
) async {
final tokens = List<String?>.filled(jobs.length, null);
var nextIndex = 0;
var failed = false;
Future<void> worker() async {
while (!failed) {
final i = nextIndex++;
if (i >= jobs.length) return;
final token = await _uploadOnePhoto(jobs[i], i, job);
if (token == null) {
failed = true;
return;
}
tokens[i] = token;
}
}
final workerCount = jobs.length < _photoConcurrency
? jobs.length
: _photoConcurrency;
await Future.wait(List.generate(workerCount, (_) => worker()));
return tokens;
}
Future<String?> _uploadOnePhoto(
({File file, GalleryItem? item}) photo,
int index,
UploadJob job,
) async {
File file;
try {
file = await optimizePhotoForUpload(photo.file, item: photo.item);
} catch (e) {
logger.w('optimize photo: $e');
file = photo.file;
}
for (var attempt = 0; attempt < _photoAttempts; attempt++) {
if (attempt > 0) {
await Future.delayed(Duration(seconds: attempt));
job.resetSlot(index);
}
try {
final url = await messagesModule.requestPhotoUploadUrl();
if (url == null || url.isEmpty) continue;
final token = await fileUploader.uploadPhoto(
Uri.parse(url),
file,
filename: _photoFilename(file),
onProgress: (sent, total) => job.report(index, sent, total),
);
if (token != null) return token;
} catch (e) {
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
}
}
return null;
}
String _photoFilename(File file) {
final segments = file.uri.pathSegments;
final name = segments.isNotEmpty ? segments.last : '';
return name.isNotEmpty ? name : 'photo.jpg';
}
void _replaceInSessionCache(
int accountId,
int chatId,
String tempId,
CachedMessage? real,
) {
final cached = MessageSessionCache.get(accountId, chatId);
if (cached == null) return;
final list = List<CachedMessage>.of(cached.messages);
final idx = list.indexWhere((m) => m.id == tempId);
if (idx == -1) return;
list[idx] = real ?? list[idx].copyWith(status: 'error');
MessageSessionCache.save(
accountId,
chatId,
list,
reachedStart: cached.reachedStart,
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import '../../core/utils/logger.dart';
import 'account/account_models.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
abstract class EntryBannerApps {
static const String sferumKey = 'entry_banner_app_sferum';
static const String digitalIdKey = 'entry_banner_app_digital_id';
static const Map<String, List<String>> iconMatchers = {
sferumKey: ['sferum'],
digitalIdKey: ['digital'],
};
static const Map<String, List<String>> titleMatchers = {
sferumKey: ['ферум'],
digitalIdKey: ['цифровой'],
};
static const Map<String, int> knownAppIds = {
sferumKey: 2340831,
digitalIdKey: 8250447,
};
static Map<String, int> appIdsFrom(Map serverConfig) {
final banners = serverConfig['settings-entry-banners'];
if (banners is! List) return const {};
final resolved = <String, int>{};
const passes = [('icon', iconMatchers), ('title', titleMatchers)];
for (final (field, matchers) in passes) {
for (final banner in banners) {
final items = (banner is Map) ? banner['items'] : null;
if (items is! List) continue;
for (final item in items) {
if (item is! Map) continue;
final appId = item['appid'];
if (appId is! int) continue;
final value = item[field]?.toString().toLowerCase() ?? '';
if (value.isEmpty) continue;
for (final entry in matchers.entries) {
if (resolved.containsKey(entry.key)) continue;
if (entry.value.any(value.contains)) resolved[entry.key] = appId;
}
}
}
}
return resolved;
}
}
const Set<String> kMiniAppOptions = {'HAS_WEBAPP', 'HAS_WEB_APP', 'WEBAPP'};
bool hasMiniAppOption(Set<String>? options) =>
options != null && options.any(kMiniAppOptions.contains);
class WebAppLaunch {
final String url;
final String? queryId;
final int botId;
const WebAppLaunch({required this.url, required this.botId, this.queryId});
}
class WebAppPhone {
final String phone;
final String hash;
final int authDate;
const WebAppPhone({
required this.phone,
required this.hash,
required this.authDate,
});
}
class ExternalCallbackResult {
final int botId;
final String? startParam;
const ExternalCallbackResult({required this.botId, this.startParam});
static ExternalCallbackResult? fromPayload(dynamic payload) {
final data = _findResponseMap(payload);
if (data == null) return null;
final botId = _asInt(data['botId'] ?? data['bot_id']);
if (botId == null) return null;
final startParam = data['startParam'] ?? data['start_param'];
return ExternalCallbackResult(
botId: botId,
startParam: startParam?.toString(),
);
}
static Map? _findResponseMap(dynamic value) {
if (value is! Map) return null;
if (value.containsKey('botId') || value.containsKey('bot_id')) return value;
for (final key in const ['data', 'result', 'response']) {
final nested = _findResponseMap(value[key]);
if (nested != null) return nested;
}
return null;
}
}
int? _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
class WebAppModule {
final Api _api;
WebAppModule(this._api);
/// device_id сессии (опкод 6 sessionInit, с учётом спуфинга) — тот же id,
/// к которому сервер привязывает Цифровой ID.
String? get sessionDeviceId => _api.deviceId;
Future<WebAppLaunch> fetchLaunch(
int botId, {
String? startParam,
int? chatId,
}) async {
if (_api.state != SessionState.online) {
throw const WebAppUnavailable('Нет соединения с сервером');
}
final normalizedStartParam =
(startParam != null && startParam.trim().isNotEmpty)
? startParam
: null;
final packet = await _api.sendRequest(Opcode.webAppInitData, {
'botId': botId,
'startParam': ?normalizedStartParam,
'chatId': ?chatId,
});
if (!packet.isOk) {
throw const WebAppUnavailable('Не удалось открыть мини-приложение');
}
final data = packet.payload;
final url = (data is Map) ? data['url'] as String? : null;
if (url == null || url.isEmpty) {
throw const WebAppUnavailable('Сервер не вернул адрес приложения');
}
final queryId = (data is Map) ? data['query_id']?.toString() : null;
return WebAppLaunch(url: url, botId: botId, queryId: queryId);
}
Future<WebAppPhone> requestPhone(int botId) async {
if (_api.state != SessionState.online) {
throw const WebAppUnavailable('Нет соединения с сервером');
}
final packet = await _api.sendRequest(Opcode.phoneWebappShare, {
'botId': botId,
});
final data = packet.payload;
if (!packet.isOk || data is! Map) {
throw const WebAppUnavailable('Не удалось передать номер телефона');
}
final phone = data['phone']?.toString();
final hash = data['hash']?.toString();
final authDate = _asInt(data['authDate'] ?? data['auth_date']);
if (phone == null ||
phone.isEmpty ||
hash == null ||
hash.isEmpty ||
authDate == null ||
authDate == 0) {
throw const WebAppUnavailable('Сервер не вернул номер телефона');
}
return WebAppPhone(phone: phone, hash: hash, authDate: authDate);
}
Future<WebAppLaunch> fetchSferum() async {
final botId = await _resolveEntryApp(EntryBannerApps.sferumKey);
if (botId == null) {
throw const WebAppUnavailable(
'Сферум сейчас недоступен. Переподключитесь и попробуйте снова.',
);
}
return fetchLaunch(botId);
}
Future<WebAppLaunch> fetchDigitalId() async {
final botId = await _resolveEntryApp(EntryBannerApps.digitalIdKey);
if (botId == null) {
throw const WebAppUnavailable(
'Цифровой ID сейчас недоступен. Переподключитесь и попробуйте снова.',
);
}
return fetchLaunch(botId);
}
Future<WebAppLaunch> handleExternalCallback(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || uri.queryParameters['externalCallback'] != '1') {
throw const WebAppUnavailable('Некорректный callback Цифрового ID');
}
if (_api.state != SessionState.online) {
throw const WebAppUnavailable('Нет соединения с сервером');
}
final packet = await _api.sendRequest(Opcode.externalCallback, {
'url': url,
});
final result = ExternalCallbackResult.fromPayload(packet.payload);
if (result == null) {
throw const WebAppUnavailable(
'Сервер не вернул данные для завершения Цифрового ID',
);
}
return fetchLaunch(result.botId, startParam: result.startParam);
}
Future<int?> _resolveEntryApp(String key) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return null;
final raw = await AppDatabase.getSyncValue(accountId, key);
final stored = int.tryParse(raw ?? '');
if (stored != null) return stored;
await LoginSyncParams.forgetServerConfig(accountId);
final known = EntryBannerApps.knownAppIds[key];
logger.w('Мини-аппа $key: id нет в конфиге, берём известный $known');
return known;
}
}
class WebAppUnavailable implements Exception {
final String message;
const WebAppUnavailable(this.message);
@override
String toString() => message;
}