Import IKAR messenger client and server
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: вход не завершён'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() ?? '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user