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;
|
||||
}
|
||||
Vendored
+379
@@ -0,0 +1,379 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../../models/bot_info.dart';
|
||||
import '../../models/chat_info.dart';
|
||||
import '../../models/contact_info.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
import '../storage/chat_members_store.dart';
|
||||
|
||||
Api? _api;
|
||||
|
||||
void attachInfoCacheApi(Api api) {
|
||||
_api = api;
|
||||
}
|
||||
|
||||
class _Entry<T> {
|
||||
T? value;
|
||||
DateTime? fetchedAt;
|
||||
DateTime? failedAt;
|
||||
Future<T?>? inFlight;
|
||||
}
|
||||
|
||||
class InfoCache<T> {
|
||||
final Duration ttl;
|
||||
final Duration failureBackoff;
|
||||
final Future<T?> Function(int id) fetcher;
|
||||
final Map<int, _Entry<T>> _entries = {};
|
||||
|
||||
InfoCache({
|
||||
required this.ttl,
|
||||
required this.fetcher,
|
||||
this.failureBackoff = const Duration(seconds: 10),
|
||||
});
|
||||
|
||||
bool _isFresh(_Entry<T> e) {
|
||||
if (e.fetchedAt == null) return false;
|
||||
return DateTime.now().difference(e.fetchedAt!) < ttl;
|
||||
}
|
||||
|
||||
bool _isInFailureBackoff(_Entry<T> e) {
|
||||
if (e.failedAt == null) return false;
|
||||
return DateTime.now().difference(e.failedAt!) < failureBackoff;
|
||||
}
|
||||
|
||||
Future<T?> get(int id, {bool forceRefresh = false}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
|
||||
if (!forceRefresh && _isFresh(entry)) {
|
||||
return Future.value(entry.value);
|
||||
}
|
||||
if (!forceRefresh && _isInFailureBackoff(entry)) {
|
||||
return Future.value(null);
|
||||
}
|
||||
if (entry.inFlight != null) return entry.inFlight!;
|
||||
|
||||
final future = _runFetch(entry, id);
|
||||
entry.inFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<T?> _runFetch(_Entry<T> entry, int id) async {
|
||||
try {
|
||||
final result = await fetcher(id);
|
||||
if (result == null) {
|
||||
entry.failedAt = DateTime.now();
|
||||
return null;
|
||||
}
|
||||
entry.value = result;
|
||||
entry.fetchedAt = DateTime.now();
|
||||
entry.failedAt = null;
|
||||
return result;
|
||||
} catch (_) {
|
||||
entry.failedAt = DateTime.now();
|
||||
return null;
|
||||
} finally {
|
||||
entry.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
T? peek(int id) {
|
||||
final entry = _entries[id];
|
||||
if (entry == null || !_isFresh(entry)) return null;
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
void invalidate(int id) => _entries.remove(id);
|
||||
void clear() => _entries.clear();
|
||||
|
||||
void putValue(int id, T value, {DateTime? at}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
entry.value = value;
|
||||
entry.fetchedAt = at ?? DateTime.now();
|
||||
entry.failedAt = null;
|
||||
}
|
||||
|
||||
void markFailed(int id, {DateTime? at}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
entry.failedAt = at ?? DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
class ContactInfoFetch {
|
||||
static final _cache = InfoCache<ContactInfo>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<ContactInfo?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static ContactInfo? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static void putContact(int id, Map<dynamic, dynamic> contact) {
|
||||
_cache.putValue(
|
||||
id,
|
||||
ContactInfo.fromMap(Map<String, dynamic>.from(contact)),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<int, ContactInfo>> getMany(
|
||||
List<int> ids, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final result = <int, ContactInfo>{};
|
||||
final missing = <int>[];
|
||||
for (final id in ids) {
|
||||
if (!forceRefresh) {
|
||||
final cached = _cache.peek(id);
|
||||
if (cached != null) {
|
||||
result[id] = cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
missing.add(id);
|
||||
}
|
||||
if (missing.isEmpty) return result;
|
||||
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return result;
|
||||
try {
|
||||
final resp = await api.sendRequest(Opcode.contactInfo, {
|
||||
'contactIds': missing,
|
||||
});
|
||||
final data = resp.payload;
|
||||
final contacts = data is Map ? data['contacts'] : null;
|
||||
if (contacts is List) {
|
||||
final now = DateTime.now();
|
||||
for (final c in contacts.whereType<Map>()) {
|
||||
final id = c['id'];
|
||||
if (id is! int) continue;
|
||||
final info = ContactInfo.fromMap(Map<String, dynamic>.from(c));
|
||||
_cache.putValue(id, info, at: now);
|
||||
result[id] = info;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<ContactInfo?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.contactInfo, {
|
||||
'contactIds': [id],
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return null;
|
||||
final contacts = data['contacts'];
|
||||
if (contacts is! List || contacts.isEmpty) return null;
|
||||
final first = contacts.first;
|
||||
if (first is! Map) return null;
|
||||
return ContactInfo.fromMap(Map<String, dynamic>.from(first));
|
||||
}
|
||||
}
|
||||
|
||||
class PresenceFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
ttl: const Duration(seconds: 60),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(
|
||||
int id, {
|
||||
bool forceRefresh = false,
|
||||
}) => _cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
static final Map<int, Map<String, dynamic>> _live = {};
|
||||
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
static Map<String, dynamic>? live(int id) => _live[id] ?? _cache.peek(id);
|
||||
|
||||
static bool isOnline(int id) => (live(id)?['status'] as int?) == 1;
|
||||
|
||||
static void apply(int id, Map<String, dynamic> presence) {
|
||||
if (id <= 0) return;
|
||||
_live[id] = presence;
|
||||
_cache.putValue(id, presence);
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
|
||||
static void clear() {
|
||||
_cache.clear();
|
||||
_live.clear();
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
static void primeAll(Map<dynamic, dynamic> presence) {
|
||||
final now = DateTime.now();
|
||||
presence.forEach((key, value) {
|
||||
if (value is! Map) return;
|
||||
final id = key is int ? key : int.tryParse(key.toString());
|
||||
if (id == null) return;
|
||||
final map = Map<String, dynamic>.from(value);
|
||||
_cache.putValue(id, map, at: now);
|
||||
_live[id] = map;
|
||||
});
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
final results = await _fetchBatch([id]);
|
||||
return results[id];
|
||||
}
|
||||
|
||||
static Future<Map<int, Map<String, dynamic>>> getMany(
|
||||
List<int> ids, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final result = <int, Map<String, dynamic>>{};
|
||||
final missing = <int>[];
|
||||
for (final id in ids) {
|
||||
if (!forceRefresh) {
|
||||
final cached = _cache.peek(id);
|
||||
if (cached != null) {
|
||||
result[id] = cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
missing.add(id);
|
||||
}
|
||||
if (missing.isNotEmpty) {
|
||||
final fetched = await _fetchBatch(missing);
|
||||
final now = DateTime.now();
|
||||
var changed = false;
|
||||
for (final id in missing) {
|
||||
final value = fetched[id];
|
||||
if (value != null) {
|
||||
_cache.putValue(id, value, at: now);
|
||||
_live[id] = value;
|
||||
result[id] = value;
|
||||
changed = true;
|
||||
} else {
|
||||
_cache.markFailed(id, at: now);
|
||||
}
|
||||
}
|
||||
if (changed) revision.value++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static const _batchSize = 100;
|
||||
|
||||
static Future<void> ensureFor(Iterable<int> ids) async {
|
||||
final wanted = <int>{};
|
||||
for (final id in ids) {
|
||||
if (id <= 0) continue;
|
||||
if (_cache.peek(id) != null) continue;
|
||||
wanted.add(id);
|
||||
}
|
||||
if (wanted.isEmpty) return;
|
||||
final list = wanted.toList();
|
||||
for (var i = 0; i < list.length; i += _batchSize) {
|
||||
final chunk = list.sublist(
|
||||
i,
|
||||
i + _batchSize > list.length ? list.length : i + _batchSize,
|
||||
);
|
||||
await getMany(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(
|
||||
List<int> ids,
|
||||
) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online || ids.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
final resp = await api.sendRequest(Opcode.contactPresence, {
|
||||
'contactIds': ids,
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return const {};
|
||||
final presence = data['presence'];
|
||||
if (presence is! Map) return const {};
|
||||
final out = <int, Map<String, dynamic>>{};
|
||||
for (final id in ids) {
|
||||
final entry = presence[id.toString()] ?? presence[id];
|
||||
if (entry is Map) {
|
||||
out[id] = Map<String, dynamic>.from(entry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class BotInfoFetch {
|
||||
static final _cache = InfoCache<BotInfo>(
|
||||
ttl: const Duration(minutes: 30),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<BotInfo?> get(int botId, {bool forceRefresh = false}) =>
|
||||
_cache.get(botId, forceRefresh: forceRefresh);
|
||||
|
||||
static BotInfo? peek(int botId) => _cache.peek(botId);
|
||||
|
||||
static List<BotCommand> commandsOf(int botId) =>
|
||||
_cache.peek(botId)?.commands ?? const [];
|
||||
|
||||
static void invalidate(int botId) => _cache.invalidate(botId);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<BotInfo?> _fetch(int botId) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.botInfo, {'botId': botId});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return null;
|
||||
final info = BotInfo.fromPayload(botId, Map<String, dynamic>.from(data));
|
||||
final contact = info.contact;
|
||||
if (contact != null) ContactInfoFetch.putContact(botId, contact.raw);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatInfoFetch {
|
||||
static final _cache = InfoCache<ChatInfo>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<ChatInfo?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static ChatInfo? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void put(int id, ChatInfo info) {
|
||||
ChatMembersStore.instance.applyChatPayload(info.raw);
|
||||
_cache.putValue(id, info);
|
||||
}
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<ChatInfo?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.chatInfo, {
|
||||
'chatIds': [id],
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return null;
|
||||
final chats = data['chats'];
|
||||
if (chats is! List || chats.isEmpty) return null;
|
||||
final first = chats.first;
|
||||
if (first is! Map) return null;
|
||||
ChatMembersStore.instance.applyChatPayload(first);
|
||||
return ChatInfo.fromMap(Map<String, dynamic>.from(first));
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import '../../backend/modules/messages.dart';
|
||||
|
||||
class CachedChatMessages {
|
||||
final List<CachedMessage> messages;
|
||||
final bool reachedStart;
|
||||
|
||||
const CachedChatMessages(this.messages, this.reachedStart);
|
||||
}
|
||||
|
||||
class MessageSessionCache {
|
||||
static const int _capacity = 24;
|
||||
|
||||
static final LinkedHashMap<String, CachedChatMessages> _store =
|
||||
LinkedHashMap<String, CachedChatMessages>();
|
||||
|
||||
static String _key(int accountId, int chatId) => '$accountId:$chatId';
|
||||
|
||||
static CachedChatMessages? get(int accountId, int chatId) {
|
||||
final key = _key(accountId, chatId);
|
||||
final entry = _store.remove(key);
|
||||
if (entry == null) return null;
|
||||
_store[key] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
static void save(
|
||||
int accountId,
|
||||
int chatId,
|
||||
List<CachedMessage> messages, {
|
||||
required bool reachedStart,
|
||||
}) {
|
||||
if (messages.isEmpty) return;
|
||||
final key = _key(accountId, chatId);
|
||||
_store.remove(key);
|
||||
_store[key] = CachedChatMessages(
|
||||
List<CachedMessage>.of(messages),
|
||||
reachedStart,
|
||||
);
|
||||
while (_store.length > _capacity) {
|
||||
_store.remove(_store.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
static void replace(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String messageId,
|
||||
CachedMessage Function(CachedMessage current) change,
|
||||
) {
|
||||
final cached = get(accountId, chatId);
|
||||
if (cached == null) return;
|
||||
final list = List<CachedMessage>.of(cached.messages);
|
||||
final idx = list.indexWhere((m) => m.id == messageId);
|
||||
if (idx == -1) return;
|
||||
list[idx] = change(list[idx]);
|
||||
save(accountId, chatId, list, reachedStart: cached.reachedStart);
|
||||
}
|
||||
|
||||
static void append(int accountId, int chatId, List<CachedMessage> added) {
|
||||
final cached = _store[_key(accountId, chatId)];
|
||||
if (cached == null) return;
|
||||
final known = {for (final message in cached.messages) message.id};
|
||||
final fresh = [
|
||||
for (final message in added)
|
||||
if (!known.contains(message.id)) message,
|
||||
];
|
||||
if (fresh.isEmpty) return;
|
||||
final merged = [...cached.messages, ...fresh]
|
||||
..sort((a, b) => a.time.compareTo(b.time));
|
||||
save(accountId, chatId, merged, reachedStart: cached.reachedStart);
|
||||
}
|
||||
|
||||
static void remove(int accountId, int chatId) =>
|
||||
_store.remove(_key(accountId, chatId));
|
||||
|
||||
static void clearAll() => _store.clear();
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SelfPresence {
|
||||
static final ValueNotifier<bool> isOnline = ValueNotifier(true);
|
||||
static final ValueNotifier<int?> lastSeenSeconds = ValueNotifier(null);
|
||||
|
||||
static int get _nowSeconds => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
static void markOnline() {
|
||||
isOnline.value = true;
|
||||
}
|
||||
|
||||
static void markOffline() {
|
||||
isOnline.value = false;
|
||||
}
|
||||
|
||||
static void markOfflineFromPing() {
|
||||
if (isOnline.value || lastSeenSeconds.value == null) {
|
||||
lastSeenSeconds.value = _nowSeconds;
|
||||
}
|
||||
isOnline.value = false;
|
||||
}
|
||||
|
||||
static void applySelfCheck({required bool online, int? seenSeconds}) {
|
||||
if (online) {
|
||||
isOnline.value = true;
|
||||
} else {
|
||||
isOnline.value = false;
|
||||
if (seenSeconds != null) lastSeenSeconds.value = seenSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'call_session.dart';
|
||||
|
||||
class ActiveCallPresentation {
|
||||
const ActiveCallPresentation({
|
||||
required this.session,
|
||||
required this.name,
|
||||
required this.avatarUrl,
|
||||
required this.isGroup,
|
||||
});
|
||||
|
||||
final CallSession session;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
final bool isGroup;
|
||||
|
||||
bool sameAs(ActiveCallPresentation other) =>
|
||||
identical(session, other.session) &&
|
||||
name == other.name &&
|
||||
avatarUrl == other.avatarUrl &&
|
||||
isGroup == other.isGroup;
|
||||
}
|
||||
|
||||
class ActiveCall {
|
||||
ActiveCall._();
|
||||
|
||||
static final ActiveCall instance = ActiveCall._();
|
||||
|
||||
final ValueNotifier<ActiveCallPresentation?> current = ValueNotifier(null);
|
||||
|
||||
final ValueNotifier<bool> screenVisible = ValueNotifier(false);
|
||||
|
||||
int _openScreens = 0;
|
||||
StreamSubscription<CallSessionState>? _stateSub;
|
||||
|
||||
void attach({
|
||||
required CallSession session,
|
||||
required String name,
|
||||
String? avatarUrl,
|
||||
bool isGroup = false,
|
||||
}) {
|
||||
if (session.currentState == CallSessionState.ended) return;
|
||||
final next = ActiveCallPresentation(
|
||||
session: session,
|
||||
name: name,
|
||||
avatarUrl: avatarUrl,
|
||||
isGroup: isGroup,
|
||||
);
|
||||
final previous = current.value;
|
||||
if (previous != null && previous.sameAs(next)) return;
|
||||
if (previous == null || !identical(previous.session, session)) {
|
||||
_stateSub?.cancel();
|
||||
_stateSub = session.stateStream.listen((state) {
|
||||
if (state == CallSessionState.ended) detach(session);
|
||||
});
|
||||
}
|
||||
_publish(current, next);
|
||||
}
|
||||
|
||||
void detach([CallSession? session]) {
|
||||
final active = current.value;
|
||||
if (active == null) return;
|
||||
if (session != null && !identical(active.session, session)) return;
|
||||
_stateSub?.cancel();
|
||||
_stateSub = null;
|
||||
_publish(current, null);
|
||||
}
|
||||
|
||||
void enterScreen() {
|
||||
_openScreens++;
|
||||
_publish(screenVisible, true);
|
||||
}
|
||||
|
||||
void leaveScreen() {
|
||||
if (_openScreens > 0) _openScreens--;
|
||||
_publish(screenVisible, _openScreens > 0);
|
||||
}
|
||||
|
||||
void _publish<T>(ValueNotifier<T> notifier, T value) {
|
||||
if (SchedulerBinding.instance.schedulerPhase ==
|
||||
SchedulerPhase.persistentCallbacks) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
notifier.value = value;
|
||||
});
|
||||
return;
|
||||
}
|
||||
notifier.value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
class AudioInputDevice {
|
||||
const AudioInputDevice({required this.id, required this.label});
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class AudioDevices {
|
||||
AudioDevices._();
|
||||
|
||||
static Future<List<AudioInputDevice>> microphones() async {
|
||||
try {
|
||||
final devices = await navigator.mediaDevices.enumerateDevices();
|
||||
final mics = <AudioInputDevice>[];
|
||||
final seen = <String>{};
|
||||
for (final device in devices) {
|
||||
if (device.kind != 'audioinput') continue;
|
||||
if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue;
|
||||
mics.add(
|
||||
AudioInputDevice(id: device.deviceId, label: device.label.trim()),
|
||||
);
|
||||
}
|
||||
return mics;
|
||||
} catch (e) {
|
||||
logger.w('[call] enumerateDevices: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static bool get switchesInsideEngine =>
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS);
|
||||
|
||||
static Future<void> selectInput(String deviceId) async {
|
||||
try {
|
||||
await Helper.selectAudioInput(deviceId);
|
||||
} catch (e) {
|
||||
logger.w('[call] selectAudioInput($deviceId): $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Object micConstraints(
|
||||
String? deviceId, {
|
||||
bool monitorCapture = false,
|
||||
}) {
|
||||
final constraints = <String, dynamic>{};
|
||||
final hasDevice = deviceId != null && deviceId.isNotEmpty;
|
||||
if (hasDevice && !switchesInsideEngine) {
|
||||
if (kIsWeb) {
|
||||
constraints['deviceId'] = deviceId;
|
||||
} else {
|
||||
constraints['optional'] = [
|
||||
{'sourceId': deviceId},
|
||||
];
|
||||
}
|
||||
}
|
||||
if (monitorCapture) {
|
||||
constraints['echoCancellation'] = true;
|
||||
constraints['noiseSuppression'] = false;
|
||||
constraints['autoGainControl'] = false;
|
||||
constraints['highpassFilter'] = false;
|
||||
}
|
||||
return constraints.isEmpty ? true : constraints;
|
||||
}
|
||||
|
||||
static Future<String?> findDevice(String token, {int attempts = 1}) async {
|
||||
for (var attempt = 0; attempt < attempts; attempt++) {
|
||||
for (final device in await microphones()) {
|
||||
if (device.id == token ||
|
||||
device.id.contains(token) ||
|
||||
device.label.contains(token)) {
|
||||
return device.id;
|
||||
}
|
||||
}
|
||||
if (attempt + 1 < attempts) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'ws2_signaling.dart';
|
||||
|
||||
enum CallMedia {
|
||||
audio('AUDIO'),
|
||||
video('VIDEO'),
|
||||
screenShare('SCREEN_SHARING'),
|
||||
movieShare('MOVIE_SHARING');
|
||||
|
||||
const CallMedia(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallMuteState {
|
||||
unmute('UNMUTE'),
|
||||
mute('MUTE'),
|
||||
mutePermanent('MUTE_PERMANENT');
|
||||
|
||||
const CallMuteState(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallRoleName {
|
||||
creator('CREATOR'),
|
||||
admin('ADMIN'),
|
||||
speaker('SPEAKER');
|
||||
|
||||
const CallRoleName(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallOption {
|
||||
requireAuthToJoin('REQUIRE_AUTH_TO_JOIN'),
|
||||
waitingHall('WAITING_HALL'),
|
||||
recurring('RECURRING'),
|
||||
feedback('FEEDBACK'),
|
||||
audienceMode('AUDIENCE_MODE'),
|
||||
asr('ASR'),
|
||||
waitForAdmin('WAIT_FOR_ADMIN'),
|
||||
adminIsHere('ADMIN_IS_HERE');
|
||||
|
||||
const CallOption(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallFeature {
|
||||
addParticipant('ADD_PARTICIPANT'),
|
||||
admin('ADMIN'),
|
||||
asr('ASR'),
|
||||
movieShare('MOVIE_SHARE'),
|
||||
record('RECORD'),
|
||||
speaker('SPEAKER');
|
||||
|
||||
const CallFeature(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallListType {
|
||||
grid('GRID'),
|
||||
side('SIDE');
|
||||
|
||||
const CallListType(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
class CallParticipantRef {
|
||||
final int id;
|
||||
final int deviceIdx;
|
||||
final bool isGroup;
|
||||
|
||||
const CallParticipantRef(this.id, {this.deviceIdx = 0, this.isGroup = false});
|
||||
|
||||
String get wire => '${isGroup ? 'g' : 'u'}$id:d$deviceIdx';
|
||||
}
|
||||
|
||||
class CallAdmin {
|
||||
final Ws2Signaling _signaling;
|
||||
|
||||
const CallAdmin(this._signaling);
|
||||
|
||||
Future<void> requestMedia(
|
||||
Set<CallMedia> media, {
|
||||
CallParticipantRef? participant,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'mute-participant',
|
||||
extra: {
|
||||
'participantId': ?participant?.wire,
|
||||
'requestedMedia': media.map((m) => m.wire).toList(),
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setMuteStates(
|
||||
Map<CallMedia, CallMuteState?> states, {
|
||||
CallParticipantRef? participant,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'mute-participant',
|
||||
extra: {
|
||||
'participantId': ?participant?.wire,
|
||||
'muteStates': {
|
||||
for (final media in CallMedia.values) media.wire: states[media]?.wire,
|
||||
},
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> muteMicrophone(
|
||||
CallParticipantRef participant, {
|
||||
bool muted = true,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'switch-micro',
|
||||
extra: {'eId': participant.wire, 'muteTarget': muted},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> muteEveryone() {
|
||||
return _signaling.sendCommand(
|
||||
'switch-micro',
|
||||
extra: const {'all': true, 'muteTarget': true},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPromoted(CallParticipantRef participant, bool promoted) {
|
||||
return _signaling.sendCommand(
|
||||
'promote-participant',
|
||||
extra: {'participantId': participant.wire, 'demote': !promoted},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setRoles(
|
||||
CallParticipantRef participant,
|
||||
List<CallRoleName> roles, {
|
||||
bool revoke = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'grant-roles',
|
||||
extra: {
|
||||
'participantId': participant.wire,
|
||||
'roles': roles.map((r) => r.wire).toList(),
|
||||
'revoke': revoke,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeParticipant(CallParticipantRef participant) {
|
||||
return _signaling.sendCommand(
|
||||
'remove-participant',
|
||||
extra: {'participantId': participant.wire},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPinned(
|
||||
CallParticipantRef participant,
|
||||
bool pinned, {
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'pin-participant',
|
||||
extra: {
|
||||
'participantId': participant.wire,
|
||||
'unpin': !pinned,
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setOptions(Map<CallOption, bool> options) {
|
||||
return _signaling.sendCommand(
|
||||
'change-options',
|
||||
extra: {
|
||||
'options': {
|
||||
for (final entry in options.entries) entry.key.wire: entry.value,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enableFeatureForRoles(
|
||||
CallFeature feature,
|
||||
List<CallRoleName> roles,
|
||||
) {
|
||||
return _signaling.sendCommand(
|
||||
'enable-feature-for-roles',
|
||||
extra: {
|
||||
'feature': feature.wire,
|
||||
'roles': roles.map((r) => r.wire).toList(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> lowerAllHands() => _signaling.sendCommand('put-hands-down');
|
||||
|
||||
Future<void> setHandRaised(bool raised, {CallParticipantRef? participant}) {
|
||||
return _setState({'hand': raised ? '1' : '0'}, participant: participant);
|
||||
}
|
||||
|
||||
Future<void> setAssistanceRequested(
|
||||
bool requested, {
|
||||
CallParticipantRef? participant,
|
||||
}) {
|
||||
return _setState({'drat': requested ? '1' : '0'}, participant: participant);
|
||||
}
|
||||
|
||||
Future<void> _setState(
|
||||
Map<String, String> state, {
|
||||
CallParticipantRef? participant,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'change-participant-state',
|
||||
extra: {
|
||||
'participantState': {'state': state},
|
||||
'participantId': ?participant?.wire,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addParticipants(
|
||||
List<String> externalIds, {
|
||||
bool? unban,
|
||||
bool showChatHistory = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'add-participant',
|
||||
extra: {
|
||||
'externalIds': externalIds,
|
||||
if (unban == true) 'unban': true,
|
||||
if (showChatHistory) 'payload': '{"show_chat_history":true}',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addParticipantByLink(String link) {
|
||||
return _signaling.sendCommand(
|
||||
'add-participant',
|
||||
extra: {'participantIdAsQRCodeLink': link},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> startRecord({
|
||||
int? movieId,
|
||||
String? name,
|
||||
String? description,
|
||||
String? privacy,
|
||||
int? groupId,
|
||||
String? albumId,
|
||||
bool streamMovie = false,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'record-start',
|
||||
extra: {
|
||||
'movieId': movieId,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'privacy': privacy,
|
||||
'groupId': groupId,
|
||||
'albumId': albumId,
|
||||
'streamMovie': streamMovie,
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopRecord({bool remove = false, String? roomId}) {
|
||||
return _signaling.sendCommand(
|
||||
'record-stop',
|
||||
extra: {if (remove) 'remove': true, 'roomId': ?roomId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> participantChunk({
|
||||
int count = 50,
|
||||
CallListType listType = CallListType.grid,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'get-participant-list-chunk',
|
||||
extra: {'count': count, 'listType': listType.wire, 'roomId': ?roomId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> waitingHall({
|
||||
int count = 50,
|
||||
String? fromId,
|
||||
bool backward = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'get-waiting-hall',
|
||||
extra: {'count': count, 'fromId': ?fromId, 'backward': backward},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import 'call_controller.dart';
|
||||
|
||||
class CallBridge {
|
||||
CallBridge._();
|
||||
static final CallBridge instance = CallBridge._();
|
||||
|
||||
static const _method = MethodChannel('ru.ikar.app/calls');
|
||||
static const _events = EventChannel('ru.ikar.app/calls_events');
|
||||
|
||||
bool _started = false;
|
||||
|
||||
bool get _android {
|
||||
try {
|
||||
return Platform.isAndroid;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void init() {
|
||||
if (_started || !_android) return;
|
||||
_started = true;
|
||||
_events.receiveBroadcastStream().listen(
|
||||
_handle,
|
||||
onError: (e) => logger.w('CallBridge.init: events stream error: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> checkInitialCall() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
_handle(await _method.invokeMethod<dynamic>('consumeInitialCall'));
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.checkInitialCall: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handle(Object? event) {
|
||||
if (event is! Map) return;
|
||||
final action = event['action']?.toString();
|
||||
if (action == 'hangup') {
|
||||
unawaited(CallController.instance.endActive());
|
||||
return;
|
||||
}
|
||||
if (action == 'ended') {
|
||||
CallController.instance.dismissIncoming();
|
||||
return;
|
||||
}
|
||||
final dataStr = event['data'];
|
||||
if (dataStr is! String) return;
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(dataStr);
|
||||
} catch (e) {
|
||||
logger.w('CallBridge._handle: action=$action jsonDecode failed: $e');
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map) return;
|
||||
CallController.instance.injectFromNative(
|
||||
decoded,
|
||||
autoAccept: action == 'answer',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> notifyAccepted({String? caller}) async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('notifyAccepted', {'caller': caller});
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.notifyAccepted: caller=$caller $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureOngoing({String? caller}) async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('ensureOngoing', {'caller': caller});
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.ensureOngoing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setScreenShare(bool enabled, {String? caller}) async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('setScreenShare', {
|
||||
'enabled': enabled,
|
||||
'caller': caller,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.setScreenShare: enabled=$enabled $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dropOngoing() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('dropOngoing');
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.dropOngoing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> notifyEnded() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('notifyEnded');
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.notifyEnded: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelIncoming() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('cancelIncoming');
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.cancelIncoming: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> canUseFullScreenIntent() async {
|
||||
if (!_android) return true;
|
||||
try {
|
||||
return await _method.invokeMethod<bool>('canUseFullScreenIntent') ?? true;
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.canUseFullScreenIntent: $e');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openFullScreenIntentSettings() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('openFullScreenIntentSettings');
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.openFullScreenIntentSettings: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../../backend/modules/calls.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
import '../push/fkm_controller.dart';
|
||||
import '../protocol/packet.dart';
|
||||
import '../utils/parse.dart';
|
||||
import 'call_bridge.dart';
|
||||
import 'call_session.dart';
|
||||
import 'conversation_params.dart';
|
||||
import 'ws2_signaling.dart';
|
||||
|
||||
class IncomingCall {
|
||||
final String conversationId;
|
||||
|
||||
final int callerId;
|
||||
final bool isVideo;
|
||||
final ConversationParams params;
|
||||
|
||||
final String? country;
|
||||
final bool? isContact;
|
||||
final String? callerName;
|
||||
|
||||
final bool autoAccept;
|
||||
|
||||
const IncomingCall({
|
||||
required this.conversationId,
|
||||
required this.callerId,
|
||||
required this.isVideo,
|
||||
required this.params,
|
||||
this.country,
|
||||
this.isContact,
|
||||
this.callerName,
|
||||
this.autoAccept = false,
|
||||
});
|
||||
}
|
||||
|
||||
class CallController {
|
||||
CallController._();
|
||||
static final CallController instance = CallController._();
|
||||
|
||||
Api? _api;
|
||||
CallsModule? _calls;
|
||||
StreamSubscription<Packet>? _pushSub;
|
||||
|
||||
final _incoming = StreamController<IncomingCall>.broadcast();
|
||||
final _ended = StreamController<void>.broadcast();
|
||||
final _canceled = StreamController<void>.broadcast();
|
||||
|
||||
bool appResumed = false;
|
||||
|
||||
Stream<IncomingCall> get incomingCalls => _incoming.stream;
|
||||
|
||||
Stream<void> get callEnded => _ended.stream;
|
||||
|
||||
Stream<void> get incomingCanceled => _canceled.stream;
|
||||
|
||||
CallSession? _active;
|
||||
StreamSubscription<CallSessionState>? _activeSub;
|
||||
CallSession? get activeSession => _active;
|
||||
|
||||
String? _activeJoinLink;
|
||||
String? get activeJoinLink => _active == null ? null : _activeJoinLink;
|
||||
bool _starting = false;
|
||||
|
||||
IncomingCall? _pending;
|
||||
IncomingCall? get pendingIncoming => _pending;
|
||||
|
||||
bool get isBusy => _active != null || _starting;
|
||||
|
||||
void init(Api api) {
|
||||
if (_api != null) return;
|
||||
_api = api;
|
||||
_calls = CallsModule(api);
|
||||
_pushSub = api.pushStream.listen(_onPush);
|
||||
}
|
||||
|
||||
void _onPush(Packet packet) {
|
||||
if (packet.opcode != Opcode.notifCallStart) return;
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
|
||||
final vcp = payload['vcp'] as String?;
|
||||
final conversationId = payload['conversationId'] as String?;
|
||||
final callerId = payload['callerId'] as int?;
|
||||
if (vcp == null || conversationId == null || callerId == null) return;
|
||||
|
||||
// Приложение свёрнуто — звонок показывает FKM отдельным уведомлением,
|
||||
// приём оттуда вернётся через injectFromNative.
|
||||
if (!appResumed) {
|
||||
unawaited(FkmController.instance.showIncomingCall(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
final params = ConversationParams.decode(vcp);
|
||||
if (params == null) return;
|
||||
|
||||
_emitIncoming(
|
||||
IncomingCall(
|
||||
conversationId: conversationId,
|
||||
callerId: callerId,
|
||||
isVideo: payload['type'] == 'VIDEO' || params.isVideo,
|
||||
params: params,
|
||||
country: payload['country'] as String?,
|
||||
isContact: payload['isContact'] as bool?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void injectFromNative(Map<dynamic, dynamic> data, {bool autoAccept = false}) {
|
||||
final vcp = data['vcp']?.toString();
|
||||
if (vcp == null || vcp.isEmpty) return;
|
||||
|
||||
final params = ConversationParams.decode(vcp);
|
||||
if (params == null) return;
|
||||
|
||||
final conversationId = (data['conversationId'] ?? data['vcId'])?.toString();
|
||||
if (conversationId == null || conversationId.isEmpty) return;
|
||||
|
||||
final callerId = parseIntOrNull(data['callerId'] ?? data['suid']);
|
||||
if (callerId == null) return;
|
||||
|
||||
final type = (data['type'] ?? data['callType'])?.toString();
|
||||
final iv = data['iv'];
|
||||
final isVideo =
|
||||
params.isVideo || type == 'VIDEO' || iv == true || iv == 'true';
|
||||
|
||||
_emitIncoming(
|
||||
IncomingCall(
|
||||
conversationId: conversationId,
|
||||
callerId: callerId,
|
||||
isVideo: isVideo,
|
||||
params: params,
|
||||
country: data['country']?.toString(),
|
||||
isContact: data['isContact'] is bool ? data['isContact'] as bool : null,
|
||||
callerName: data['userName']?.toString(),
|
||||
autoAccept: autoAccept,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _emitIncoming(IncomingCall incoming) {
|
||||
if (isBusy) return;
|
||||
if (_pending?.conversationId == incoming.conversationId) return;
|
||||
_pending = incoming;
|
||||
_incoming.add(incoming);
|
||||
}
|
||||
|
||||
void dismissIncoming() {
|
||||
if (_pending == null) return;
|
||||
_pending = null;
|
||||
_canceled.add(null);
|
||||
}
|
||||
|
||||
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) =>
|
||||
_startExclusive(() async {
|
||||
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
|
||||
final config = Ws2Config.fromEndpoint(
|
||||
out.endpoint,
|
||||
userId: out.callsUserId,
|
||||
device: _api?.callsDevice,
|
||||
osVersion: _api?.callsOsVersion,
|
||||
);
|
||||
final session = CallSession(ws2Config: config, role: CallRole.caller);
|
||||
return _launch(session, session.start);
|
||||
});
|
||||
|
||||
// #***! конференция со ссылкой
|
||||
Future<CreatedCall> createConference() async {
|
||||
if (isBusy) throw StateError('уже идёт звонок');
|
||||
return _calls!.createConference();
|
||||
}
|
||||
|
||||
Future<CallLinkPreview?> previewCallLink(String url) =>
|
||||
_calls!.resolveCallLink(url);
|
||||
|
||||
Future<CallSession> joinByLink(String token, {bool isVideo = false}) =>
|
||||
_startExclusive(() async {
|
||||
final params = await _calls!.joinByLink(token, isVideo: isVideo);
|
||||
final config = Ws2Config.fromEndpoint(
|
||||
params.endpoint,
|
||||
userId: params.callsUserId,
|
||||
device: _api?.callsDevice,
|
||||
osVersion: _api?.callsOsVersion,
|
||||
);
|
||||
final session = CallSession(
|
||||
ws2Config: config,
|
||||
role: CallRole.joiner,
|
||||
isGroup: true,
|
||||
);
|
||||
_activeJoinLink = token;
|
||||
return _launch(session, session.start);
|
||||
});
|
||||
|
||||
Future<CallSession> _startExclusive(
|
||||
Future<CallSession> Function() start,
|
||||
) async {
|
||||
if (isBusy) throw StateError('уже идёт звонок');
|
||||
_starting = true;
|
||||
try {
|
||||
return await start();
|
||||
} finally {
|
||||
_starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CallSession> acceptIncoming(IncomingCall call) async {
|
||||
_pending = null;
|
||||
CallBridge.instance.cancelIncoming();
|
||||
final config = Ws2Config.fromVcp(
|
||||
call.params,
|
||||
conversationId: call.conversationId,
|
||||
device: _api?.callsDevice,
|
||||
osVersion: _api?.callsOsVersion,
|
||||
);
|
||||
final session = CallSession(
|
||||
ws2Config: config,
|
||||
params: call.params,
|
||||
role: CallRole.callee,
|
||||
);
|
||||
return _launch(session, () async {
|
||||
await session.start();
|
||||
await session.accept();
|
||||
}, caller: call.callerName);
|
||||
}
|
||||
|
||||
Future<void> rejectIncoming(IncomingCall call) async {
|
||||
_pending = null;
|
||||
CallBridge.instance.notifyEnded();
|
||||
final config = Ws2Config.fromVcp(
|
||||
call.params,
|
||||
conversationId: call.conversationId,
|
||||
device: _api?.callsDevice,
|
||||
osVersion: _api?.callsOsVersion,
|
||||
);
|
||||
final signaling = Ws2Signaling(config);
|
||||
try {
|
||||
await signaling.connect();
|
||||
await signaling.hangup(reason: 'REJECTED');
|
||||
} catch (_) {
|
||||
} finally {
|
||||
await signaling.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> endActive() => _active?.hangup() ?? Future.value();
|
||||
|
||||
Future<bool> sendMicSignal(bool enabled) async {
|
||||
final session = _active;
|
||||
if (session == null) return false;
|
||||
await session.sendAudioEnabledSignal(enabled);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<CallSession> _launch(
|
||||
CallSession session,
|
||||
Future<void> Function() open, {
|
||||
String? caller,
|
||||
}) async {
|
||||
_bind(session);
|
||||
try {
|
||||
await open();
|
||||
} catch (_) {
|
||||
await _release(session);
|
||||
try {
|
||||
await session.hangup();
|
||||
} catch (_) {}
|
||||
rethrow;
|
||||
}
|
||||
CallBridge.instance.notifyAccepted(caller: caller);
|
||||
return session;
|
||||
}
|
||||
|
||||
void _bind(CallSession session) {
|
||||
unawaited(_activeSub?.cancel());
|
||||
_active = session;
|
||||
_activeSub = session.stateStream.listen((state) {
|
||||
if (state != CallSessionState.ended) return;
|
||||
unawaited(_release(session));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _release(CallSession session) async {
|
||||
if (!identical(_active, session)) return;
|
||||
_active = null;
|
||||
_activeJoinLink = null;
|
||||
await _activeSub?.cancel();
|
||||
_activeSub = null;
|
||||
CallBridge.instance.notifyEnded();
|
||||
_ended.add(null);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_activeSub?.cancel();
|
||||
_pushSub?.cancel();
|
||||
_incoming.close();
|
||||
_ended.close();
|
||||
_canceled.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CallInfo {
|
||||
String? conversationId;
|
||||
String? topology;
|
||||
|
||||
String? peerPlatform;
|
||||
String? peerEngine;
|
||||
|
||||
String? peerIp;
|
||||
String? peerNetwork;
|
||||
String? path;
|
||||
|
||||
String? audioCodec;
|
||||
bool record = false;
|
||||
bool denoise = false;
|
||||
bool animoji = false;
|
||||
|
||||
String? region;
|
||||
String? dtlsFingerprint;
|
||||
final List<String> stun = [];
|
||||
final List<String> turn = [];
|
||||
}
|
||||
|
||||
class CallParse {
|
||||
static Map<String, String> candidate(String c) {
|
||||
final parts = c.trim().split(RegExp(r'\s+'));
|
||||
String? at(int i) => (i >= 0 && i < parts.length) ? parts[i] : null;
|
||||
int idx(String k) => parts.indexOf(k);
|
||||
final r = <String, String>{};
|
||||
final tr = at(2);
|
||||
if (tr != null) r['transport'] = tr.toUpperCase();
|
||||
final ip = at(4);
|
||||
if (ip != null) r['ip'] = ip;
|
||||
final port = at(5);
|
||||
if (port != null) r['port'] = port;
|
||||
final typ = idx('typ');
|
||||
if (typ != -1) r['type'] = at(typ + 1) ?? '';
|
||||
final nc = idx('network-cost');
|
||||
if (nc != -1) r['cost'] = at(nc + 1) ?? '';
|
||||
return r;
|
||||
}
|
||||
|
||||
static String networkLabel(String? cost) {
|
||||
switch (cost) {
|
||||
case '0':
|
||||
return 'VPN';
|
||||
case '10':
|
||||
return 'Wi-Fi / Ethernet';
|
||||
case '50':
|
||||
return 'неизвестно';
|
||||
case '900':
|
||||
case '999':
|
||||
return 'сотовая';
|
||||
default:
|
||||
return (cost == null || cost.isEmpty) ? '—' : 'cost=$cost';
|
||||
}
|
||||
}
|
||||
|
||||
static String engine(String sdp) {
|
||||
for (final line in const LineSplitter().convert(sdp)) {
|
||||
if (!line.startsWith('o=')) continue;
|
||||
final l = line.toLowerCase();
|
||||
if (l.contains('mozilla') || l.contains('sdparta'))
|
||||
return 'Firefox (web)';
|
||||
if (l.contains('gstreamer')) return 'GStreamer';
|
||||
return 'нативный libwebrtc';
|
||||
}
|
||||
return 'неизвестно';
|
||||
}
|
||||
|
||||
static String? audioCodec(String sdp) {
|
||||
for (final line in const LineSplitter().convert(sdp)) {
|
||||
if (line.startsWith('a=rtpmap:') && line.toLowerCase().contains('opus')) {
|
||||
final i = line.indexOf(' ');
|
||||
if (i != -1) return line.substring(i + 1).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? fingerprint(String sdp) {
|
||||
for (final line in const LineSplitter().convert(sdp)) {
|
||||
if (line.startsWith('a=fingerprint:')) {
|
||||
return line.substring('a=fingerprint:'.length).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool hasAnimoji(String sdp) => sdp.contains('animoji');
|
||||
|
||||
static bool isServerIp(String ip) => ip.startsWith('155.212.');
|
||||
|
||||
static String pathLabel(String? localType, String? remoteType) {
|
||||
final relay = localType == 'relay' || remoteType == 'relay';
|
||||
final via = relay ? 'через сервер (TURN)' : 'прямое (P2P)';
|
||||
String t(String? v) => v ?? '?';
|
||||
return '$via · ${t(localType)} ↔ ${t(remoteType)}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
class CallLink {
|
||||
static const String base = 'https://ikar.kusoft.xyz/joincall/';
|
||||
|
||||
static final RegExp _pattern = RegExp(
|
||||
r'^https?://(?:[^/\s]+\.)?max\.ru/joincall/([A-Za-z0-9_-]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final RegExp _rawPattern = RegExp(
|
||||
r'^(?:joincall/)?([A-Za-z0-9_-]+)$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static bool isCallLink(String url) => token(url) != null;
|
||||
|
||||
static String? token(String url) => _pattern.firstMatch(url.trim())?.group(1);
|
||||
|
||||
static String? normalizeToken(String raw) {
|
||||
final value = raw.trim();
|
||||
return token(value) ?? _rawPattern.firstMatch(value)?.group(1);
|
||||
}
|
||||
|
||||
static String url(String token) => '$base$token';
|
||||
|
||||
static String path(String token) => 'joincall/$token';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
enum CameraFacing { front, back, external }
|
||||
|
||||
class VideoInputDevice {
|
||||
const VideoInputDevice({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.facing,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
final CameraFacing facing;
|
||||
}
|
||||
|
||||
class CameraDevices {
|
||||
CameraDevices._();
|
||||
|
||||
static Future<List<VideoInputDevice>> cameras() async {
|
||||
try {
|
||||
final devices = await navigator.mediaDevices.enumerateDevices();
|
||||
final cameras = <VideoInputDevice>[];
|
||||
final seen = <String>{};
|
||||
for (final device in devices) {
|
||||
if (device.kind != 'videoinput') continue;
|
||||
if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue;
|
||||
final label = device.label.trim();
|
||||
cameras.add(
|
||||
VideoInputDevice(
|
||||
id: device.deviceId,
|
||||
label: label,
|
||||
facing: facingOf(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
return cameras;
|
||||
} catch (e) {
|
||||
logger.w('[media] enumerate cameras: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static CameraFacing facingOf(String label) {
|
||||
final lower = label.toLowerCase();
|
||||
if (lower.contains('front') || lower.contains('user')) {
|
||||
return CameraFacing.front;
|
||||
}
|
||||
if (lower.contains('back') ||
|
||||
lower.contains('rear') ||
|
||||
lower.contains('environment')) {
|
||||
return CameraFacing.back;
|
||||
}
|
||||
return CameraFacing.external;
|
||||
}
|
||||
|
||||
static Object constraints(String? deviceId) {
|
||||
if (deviceId == null || deviceId.isEmpty) return true;
|
||||
if (kIsWeb) return <String, dynamic>{'deviceId': deviceId};
|
||||
return <String, dynamic>{
|
||||
'optional': [
|
||||
{'sourceId': deviceId},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:kusoft/kusoft.dart' as kb;
|
||||
|
||||
/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше
|
||||
/// входящего звонка (opcode 137) и в ответе на инициацию исходящего.
|
||||
///
|
||||
/// Формат строки: `<rawLen>:<base64(LZ4-block)>`. После распаковки —
|
||||
/// компактный JSON с короткими ключами. Расшифровка повторяет
|
||||
/// `ru.ok.android.externcalls.sdk.api.ConversationParams.decode`.
|
||||
class ConversationParams {
|
||||
/// Токен авторизации в сигналинге звонка.
|
||||
final String token;
|
||||
|
||||
/// WebSocket сигналинга, напр. `wss://videowebrtc.okcdn.ru/ws2`.
|
||||
final String wsEndpoint;
|
||||
final List<String> wsIps;
|
||||
|
||||
/// HTTP/3 web-transport fallback, напр. `https://videowebrtc.okcdn.ru:23456/wt`.
|
||||
final String? wtEndpoint;
|
||||
final List<String> wtIps;
|
||||
|
||||
/// API звонков, напр. `https://calls.okcdn.ru`.
|
||||
final String? callsApiEndpoint;
|
||||
final List<String> callsApiIps;
|
||||
|
||||
/// Тип клиента, напр. `one_me`.
|
||||
final String? clientType;
|
||||
|
||||
/// Время истечения параметров (unix-секунды).
|
||||
final int? expiresAt;
|
||||
|
||||
final String? stun;
|
||||
final List<String> turn;
|
||||
final String? turnUser;
|
||||
final String? turnPassword;
|
||||
|
||||
final bool isVideo;
|
||||
|
||||
const ConversationParams({
|
||||
required this.token,
|
||||
required this.wsEndpoint,
|
||||
this.wsIps = const [],
|
||||
this.wtEndpoint,
|
||||
this.wtIps = const [],
|
||||
this.callsApiEndpoint,
|
||||
this.callsApiIps = const [],
|
||||
this.clientType,
|
||||
this.expiresAt,
|
||||
this.stun,
|
||||
this.turn = const [],
|
||||
this.turnUser,
|
||||
this.turnPassword,
|
||||
this.isVideo = false,
|
||||
});
|
||||
|
||||
/// ICE-серверы в формате, который ожидает `flutter_webrtc`
|
||||
/// (`RTCPeerConnection`).
|
||||
List<Map<String, dynamic>> get iceServers {
|
||||
final servers = <Map<String, dynamic>>[];
|
||||
if (stun != null && stun!.isNotEmpty) {
|
||||
servers.add({'urls': stun});
|
||||
}
|
||||
if (turn.isNotEmpty) {
|
||||
servers.add({
|
||||
'urls': turn,
|
||||
if (turnUser != null) 'username': turnUser,
|
||||
if (turnPassword != null) 'credential': turnPassword,
|
||||
});
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// `true`, если параметры ещё действительны (с запасом в 5 секунд).
|
||||
bool get isExpired {
|
||||
if (expiresAt == null) return false;
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return nowSec >= expiresAt! - 5;
|
||||
}
|
||||
|
||||
/// Распаковывает и парсит строку `vcp` через Rust-ядро (kusoft). Возвращает
|
||||
/// `null`, если формат не распознан. Требует инициализации `initKusoft()`.
|
||||
static ConversationParams? decode(String vcp) {
|
||||
final kb.CallParams? p = kb.decodeVcp(vcp: vcp, conversationId: '');
|
||||
if (p == null) return null;
|
||||
return ConversationParams(
|
||||
token: p.token,
|
||||
wsEndpoint: p.wsEndpoint,
|
||||
stun: p.stun,
|
||||
turn: p.turn,
|
||||
turnUser: p.turnUser,
|
||||
turnPassword: p.turnPassword,
|
||||
isVideo: p.isVideo,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
class PulseSource {
|
||||
const PulseSource({
|
||||
required this.name,
|
||||
required this.label,
|
||||
required this.isMonitor,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String label;
|
||||
final bool isMonitor;
|
||||
}
|
||||
|
||||
class PulseRouteException implements Exception {
|
||||
const PulseRouteException(this.source);
|
||||
|
||||
final String source;
|
||||
|
||||
@override
|
||||
String toString() => source;
|
||||
}
|
||||
|
||||
class PulseAudio {
|
||||
PulseAudio._();
|
||||
|
||||
static const String bridgePrefix = 'qlyra_capture_';
|
||||
|
||||
static String? _bridgeModule;
|
||||
static String? _bridgeMaster;
|
||||
static String? _bridgeSource;
|
||||
|
||||
static bool get supported => !kIsWeb && Platform.isLinux;
|
||||
|
||||
static String get _bridgeName => '$bridgePrefix$pid';
|
||||
|
||||
static Future<ProcessResult?> _pactl(List<String> args) async {
|
||||
if (!supported) return null;
|
||||
try {
|
||||
return await Process.run(
|
||||
'pactl',
|
||||
args,
|
||||
stdoutEncoding: utf8,
|
||||
stderrEncoding: utf8,
|
||||
);
|
||||
} on ProcessException catch (e) {
|
||||
logger.w('[call][pulse] pactl ${args.first}: ${e.message}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> isAvailable() async =>
|
||||
(await _pactl(const ['info']))?.exitCode == 0;
|
||||
|
||||
static Future<List<PulseSource>> sources() async {
|
||||
final result = await _pactl(const ['-f', 'json', 'list', 'sources']);
|
||||
if (result == null || result.exitCode != 0) return const [];
|
||||
return parseSources(result.stdout as String);
|
||||
}
|
||||
|
||||
static List<PulseSource> parseSources(String json) {
|
||||
final List<dynamic> entries;
|
||||
try {
|
||||
entries = jsonDecode(json) as List<dynamic>;
|
||||
} catch (e) {
|
||||
logger.w('[call][pulse] список источников: $e');
|
||||
return const [];
|
||||
}
|
||||
final sources = <PulseSource>[];
|
||||
for (final entry in entries.whereType<Map<String, dynamic>>()) {
|
||||
final name = entry['name'];
|
||||
if (name is! String || name.isEmpty) continue;
|
||||
if (name.startsWith(bridgePrefix)) continue;
|
||||
final monitorOf = entry['monitor_source'];
|
||||
final isMonitor = monitorOf is String && monitorOf.isNotEmpty;
|
||||
sources.add(
|
||||
PulseSource(
|
||||
name: name,
|
||||
label: _labelOf(entry, name, isMonitor),
|
||||
isMonitor: isMonitor,
|
||||
),
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
static Future<PulseSource?> find(String name) async {
|
||||
for (final source in await sources()) {
|
||||
if (source.name == name) return source;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> openBridge(String master) async {
|
||||
if (_bridgeMaster == master && _bridgeSource != null) return _bridgeSource;
|
||||
await closeBridge();
|
||||
await _dropStaleBridges();
|
||||
final name = _bridgeName;
|
||||
final result = await _pactl([
|
||||
'load-module',
|
||||
'module-remap-source',
|
||||
'master=$master',
|
||||
'source_name=$name',
|
||||
'source_properties=device.description=$name',
|
||||
]);
|
||||
if (result == null || result.exitCode != 0) {
|
||||
logger.w('[call][pulse] remap-source($master): ${result?.stderr}');
|
||||
return null;
|
||||
}
|
||||
final module = (result.stdout as String).trim();
|
||||
if (module.isEmpty) return null;
|
||||
_bridgeModule = module;
|
||||
_bridgeMaster = master;
|
||||
_bridgeSource = name;
|
||||
logger.i('[call][pulse] мост $name ← $master (модуль $module)');
|
||||
return name;
|
||||
}
|
||||
|
||||
static Future<void> closeBridge() async {
|
||||
final module = _bridgeModule;
|
||||
_bridgeModule = null;
|
||||
_bridgeMaster = null;
|
||||
_bridgeSource = null;
|
||||
if (module == null) return;
|
||||
await _pactl(['unload-module', module]);
|
||||
}
|
||||
|
||||
static Future<void> _dropStaleBridges() async {
|
||||
final result = await _pactl(const ['list', 'modules', 'short']);
|
||||
if (result == null || result.exitCode != 0) return;
|
||||
for (final line in const LineSplitter().convert(result.stdout as String)) {
|
||||
final columns = line.split('\t');
|
||||
if (columns.length < 3) continue;
|
||||
final owner = _bridgeOwnerPid(columns[2]);
|
||||
if (owner == null || Directory('/proc/$owner').existsSync()) continue;
|
||||
logger.i('[call][pulse] снимаю зависший мост процесса $owner');
|
||||
await _pactl(['unload-module', columns[0]]);
|
||||
}
|
||||
}
|
||||
|
||||
static int? _bridgeOwnerPid(String argument) {
|
||||
final match = RegExp('$bridgePrefix([0-9]+)').firstMatch(argument);
|
||||
return match == null ? null : int.tryParse(match.group(1)!);
|
||||
}
|
||||
|
||||
static String _labelOf(
|
||||
Map<String, dynamic> entry,
|
||||
String name,
|
||||
bool isMonitor,
|
||||
) {
|
||||
final description = entry['description'];
|
||||
if (_usable(description)) return description as String;
|
||||
final properties = entry['properties'];
|
||||
final device = properties is Map ? properties['device.description'] : null;
|
||||
if (_usable(device)) {
|
||||
return isMonitor ? 'Monitor of $device' : device as String;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
static bool _usable(Object? value) =>
|
||||
value is String && value.isNotEmpty && value != '(null)';
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
class MsgpackWriter {
|
||||
final BytesBuilder _out = BytesBuilder();
|
||||
|
||||
Uint8List takeBytes() => _out.takeBytes();
|
||||
|
||||
void raw(int byte) => _out.addByte(byte);
|
||||
|
||||
void nil() => raw(0xC0);
|
||||
|
||||
void boolean(bool value) => raw(value ? 0xC3 : 0xC2);
|
||||
|
||||
void integer(int value) {
|
||||
if (value >= 0) {
|
||||
if (value < 0x80) return raw(value);
|
||||
if (value <= 0xFF) {
|
||||
raw(0xCC);
|
||||
return raw(value);
|
||||
}
|
||||
if (value <= 0xFFFF) {
|
||||
raw(0xCD);
|
||||
return _uint(value, 2);
|
||||
}
|
||||
if (value <= 0xFFFFFFFF) {
|
||||
raw(0xCE);
|
||||
return _uint(value, 4);
|
||||
}
|
||||
raw(0xCF);
|
||||
return _uint(value, 8);
|
||||
}
|
||||
if (value >= -32) return raw(0xE0 | (value + 32));
|
||||
if (value >= -128) {
|
||||
raw(0xD0);
|
||||
return _uint(value & 0xFF, 1);
|
||||
}
|
||||
if (value >= -32768) {
|
||||
raw(0xD1);
|
||||
return _uint(value & 0xFFFF, 2);
|
||||
}
|
||||
if (value >= -2147483648) {
|
||||
raw(0xD2);
|
||||
return _uint(value & 0xFFFFFFFF, 4);
|
||||
}
|
||||
raw(0xD3);
|
||||
_uint(value, 8);
|
||||
}
|
||||
|
||||
void string(String value) {
|
||||
final bytes = utf8.encode(value);
|
||||
final length = bytes.length;
|
||||
if (length < 32) {
|
||||
raw(0xA0 | length);
|
||||
} else if (length <= 0xFF) {
|
||||
raw(0xD9);
|
||||
raw(length);
|
||||
} else if (length <= 0xFFFF) {
|
||||
raw(0xDA);
|
||||
_uint(length, 2);
|
||||
} else {
|
||||
raw(0xDB);
|
||||
_uint(length, 4);
|
||||
}
|
||||
_out.add(bytes);
|
||||
}
|
||||
|
||||
void arrayHeader(int length) {
|
||||
if (length < 16) return raw(0x90 | length);
|
||||
if (length <= 0xFFFF) {
|
||||
raw(0xDC);
|
||||
return _uint(length, 2);
|
||||
}
|
||||
raw(0xDD);
|
||||
_uint(length, 4);
|
||||
}
|
||||
|
||||
void _uint(int value, int bytes) {
|
||||
for (var shift = (bytes - 1) * 8; shift >= 0; shift -= 8) {
|
||||
raw((value >> shift) & 0xFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MsgpackReader {
|
||||
final Uint8List _data;
|
||||
int _pos = 0;
|
||||
|
||||
MsgpackReader(this._data);
|
||||
|
||||
bool get exhausted => _pos >= _data.length;
|
||||
|
||||
bool get nextIsString {
|
||||
final b = _data[_pos];
|
||||
return (b & 0xE0) == 0xA0 || b == 0xD9 || b == 0xDA || b == 0xDB;
|
||||
}
|
||||
|
||||
int readInt() {
|
||||
final b = _data[_pos++];
|
||||
if (b < 0x80) return b;
|
||||
if (b >= 0xE0) return b - 256;
|
||||
switch (b) {
|
||||
case 0xCC:
|
||||
return _uint(1);
|
||||
case 0xCD:
|
||||
return _uint(2);
|
||||
case 0xCE:
|
||||
return _uint(4);
|
||||
case 0xCF:
|
||||
return _uint(8);
|
||||
case 0xD0:
|
||||
final v = _uint(1);
|
||||
return v >= 0x80 ? v - 0x100 : v;
|
||||
case 0xD1:
|
||||
final v = _uint(2);
|
||||
return v >= 0x8000 ? v - 0x10000 : v;
|
||||
case 0xD2:
|
||||
final v = _uint(4);
|
||||
return v >= 0x80000000 ? v - 0x100000000 : v;
|
||||
case 0xD3:
|
||||
return _uint(8);
|
||||
}
|
||||
throw FormatException('не целое: 0x${b.toRadixString(16)}');
|
||||
}
|
||||
|
||||
String readString() {
|
||||
final b = _data[_pos++];
|
||||
int length;
|
||||
if ((b & 0xE0) == 0xA0) {
|
||||
length = b & 0x1F;
|
||||
} else if (b == 0xD9) {
|
||||
length = _uint(1);
|
||||
} else if (b == 0xDA) {
|
||||
length = _uint(2);
|
||||
} else if (b == 0xDB) {
|
||||
length = _uint(4);
|
||||
} else {
|
||||
throw FormatException('не строка: 0x${b.toRadixString(16)}');
|
||||
}
|
||||
final value = utf8.decode(_data.sublist(_pos, _pos + length));
|
||||
_pos += length;
|
||||
return value;
|
||||
}
|
||||
|
||||
int readMapHeader() {
|
||||
final b = _data[_pos++];
|
||||
if ((b & 0xF0) == 0x80) return b & 0x0F;
|
||||
if (b == 0xDE) return _uint(2);
|
||||
if (b == 0xDF) return _uint(4);
|
||||
throw FormatException('не map: 0x${b.toRadixString(16)}');
|
||||
}
|
||||
|
||||
int readArrayHeader() {
|
||||
final b = _data[_pos++];
|
||||
if ((b & 0xF0) == 0x90) return b & 0x0F;
|
||||
if (b == 0xDC) return _uint(2);
|
||||
if (b == 0xDD) return _uint(4);
|
||||
throw FormatException('не array: 0x${b.toRadixString(16)}');
|
||||
}
|
||||
|
||||
int _uint(int bytes) {
|
||||
var value = 0;
|
||||
for (var i = 0; i < bytes; i++) {
|
||||
value = (value << 8) | _data[_pos++];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class SfuLayoutItem {
|
||||
final String trackKey;
|
||||
final int width;
|
||||
final int height;
|
||||
|
||||
const SfuLayoutItem({
|
||||
required this.trackKey,
|
||||
this.width = 640,
|
||||
this.height = 360,
|
||||
});
|
||||
}
|
||||
|
||||
class SfuCommandChannel {
|
||||
static const int _commandDisplayLayout = 0;
|
||||
static const int _fitMode = 0;
|
||||
|
||||
static const int _notifyAliases = 1;
|
||||
static const int _notifySlots = 2;
|
||||
static const int _notifyAudioLevels = 6;
|
||||
|
||||
RTCDataChannel? _command;
|
||||
int _sequence = 1;
|
||||
|
||||
final Map<int, String> _aliases = {};
|
||||
final _slots = StreamController<Map<String, int>>.broadcast();
|
||||
final _levels = StreamController<Map<String, int>>.broadcast();
|
||||
|
||||
Stream<Map<String, int>> get slotUpdates => _slots.stream;
|
||||
Stream<Map<String, int>> get audioLevels => _levels.stream;
|
||||
|
||||
void bind(RTCDataChannel channel) {
|
||||
if (channel.label == 'producerCommand') {
|
||||
_command = channel;
|
||||
channel.onMessage = _onCommandReply;
|
||||
return;
|
||||
}
|
||||
if (channel.label != 'producerNotification') return;
|
||||
channel.onMessage = _onNotification;
|
||||
}
|
||||
|
||||
bool get ready => _command?.state == RTCDataChannelState.RTCDataChannelOpen;
|
||||
|
||||
Future<bool> sendDisplayLayout(
|
||||
List<SfuLayoutItem> items, {
|
||||
bool snapshot = true,
|
||||
}) async {
|
||||
final channel = _command;
|
||||
if (channel == null) return false;
|
||||
if (channel.state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
logger.w('[call][sfu] producerCommand не открыт, слои не отправлены');
|
||||
return false;
|
||||
}
|
||||
|
||||
final writer = MsgpackWriter()
|
||||
..integer(_commandDisplayLayout)
|
||||
..integer(0)
|
||||
..integer(_sequence++)
|
||||
..boolean(snapshot);
|
||||
|
||||
if (items.isEmpty) {
|
||||
writer.nil();
|
||||
} else {
|
||||
writer.arrayHeader(items.length * 2);
|
||||
for (final item in items) {
|
||||
writer
|
||||
..string(item.trackKey)
|
||||
..integer(0)
|
||||
..nil()
|
||||
..integer(item.width)
|
||||
..integer(item.height)
|
||||
..integer(_fitMode);
|
||||
}
|
||||
}
|
||||
writer.nil();
|
||||
|
||||
final payload = writer.takeBytes();
|
||||
try {
|
||||
await channel.send(RTCDataChannelMessage.fromBinary(payload));
|
||||
logger.i(
|
||||
'[call][sfu] update-display-layout: '
|
||||
'${items.map((i) => i.trackKey).join(', ')} '
|
||||
'raw=${payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.w('[call][sfu] update-display-layout failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCommandReply(RTCDataChannelMessage message) {
|
||||
if (!message.isBinary) return;
|
||||
final bytes = message.binary;
|
||||
final head = bytes.length > 32 ? bytes.sublist(0, 32) : bytes;
|
||||
final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
try {
|
||||
final reader = MsgpackReader(bytes);
|
||||
final type = reader.readInt();
|
||||
final version = reader.readInt();
|
||||
final error = reader.readInt();
|
||||
if (error != 0) {
|
||||
logger.w(
|
||||
'[call][sfu] command reply type=$type version=$version '
|
||||
'ERROR=$error raw=$hex',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.i('[call][sfu] command reply type=$type ok raw=$hex');
|
||||
} catch (e) {
|
||||
logger.w('[call][sfu] command reply parse failed: $e raw=$hex');
|
||||
}
|
||||
}
|
||||
|
||||
int _dumped = 0;
|
||||
|
||||
void _onNotification(RTCDataChannelMessage message) {
|
||||
if (!message.isBinary) return;
|
||||
if (_dumped < 12) {
|
||||
_dumped++;
|
||||
final bytes = message.binary;
|
||||
final head = bytes.length > 64 ? bytes.sublist(0, 64) : bytes;
|
||||
logger.i(
|
||||
'[call][sfu] notify raw len=${bytes.length} '
|
||||
'${head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
||||
);
|
||||
}
|
||||
final bytes = message.binary;
|
||||
if (bytes.isEmpty) return;
|
||||
final type = bytes[0];
|
||||
final reader = MsgpackReader(Uint8List.sublistView(bytes, 1));
|
||||
try {
|
||||
switch (type) {
|
||||
case _notifyAliases:
|
||||
final count = reader.readMapHeader();
|
||||
for (var i = 0; i < count; i++) {
|
||||
final key = reader.readString();
|
||||
_aliases[reader.readInt()] = key;
|
||||
}
|
||||
break;
|
||||
case _notifySlots:
|
||||
final count = reader.readArrayHeader();
|
||||
final slots = <String, int>{};
|
||||
for (var i = 0; i < count; i++) {
|
||||
final key = _aliases[reader.readInt()];
|
||||
if (key != null) slots[key] = i;
|
||||
}
|
||||
logger.i('[call][sfu] slots: $slots');
|
||||
if (!_slots.isClosed) _slots.add(slots);
|
||||
break;
|
||||
case _notifyAudioLevels:
|
||||
final count = reader.readMapHeader();
|
||||
final levels = <String, int>{};
|
||||
for (var i = 0; i < count; i++) {
|
||||
final key = _aliases[reader.readInt()];
|
||||
final level = reader.readInt();
|
||||
if (key != null) levels[key] = level;
|
||||
}
|
||||
if (!_levels.isClosed) _levels.add(levels);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('[call][sfu] notify type=$type parse failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_command = null;
|
||||
_aliases.clear();
|
||||
if (!_slots.isClosed) await _slots.close();
|
||||
if (!_levels.isClosed) await _levels.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../transport/ikar_network.dart';
|
||||
|
||||
import 'conversation_params.dart';
|
||||
|
||||
/// Параметры подключения к сигналинг-сокету ws2.
|
||||
///
|
||||
/// Строится из двух источников:
|
||||
/// - входящий звонок: [Ws2Config.fromVcp] (параметры из `vcp` пуша opcode 137);
|
||||
/// - исходящий звонок: [Ws2Config.fromEndpoint] (`endpoint` из ответа opcode 78,
|
||||
/// в нём уже вшит токен — дописываем только клиентские параметры).
|
||||
class Ws2Config {
|
||||
/// Готовый URL подключения к ws2.
|
||||
final Uri uri;
|
||||
|
||||
/// Внутренний id пользователя в системе звонков.
|
||||
final int userId;
|
||||
|
||||
const Ws2Config({required this.uri, required this.userId});
|
||||
|
||||
static const defaultCapabilities = '3c02f';
|
||||
static const _appVersion = 'sdk-0.2.1.3';
|
||||
static const defaultDevice = 'Android/Unknown';
|
||||
static const defaultOsVersion = '34';
|
||||
|
||||
/// Входящий звонок: из распакованных параметров [ConversationParams].
|
||||
/// `userId` — часть после `:` в [ConversationParams.turnUser].
|
||||
factory Ws2Config.fromVcp(
|
||||
ConversationParams params, {
|
||||
required String conversationId,
|
||||
String capabilities = defaultCapabilities,
|
||||
String? device,
|
||||
String? osVersion,
|
||||
}) {
|
||||
final userId = int.tryParse((params.turnUser ?? '').split(':').last) ?? 0;
|
||||
final uri = Uri.parse(params.wsEndpoint).replace(
|
||||
queryParameters: {
|
||||
'userId': '$userId',
|
||||
'token': params.token,
|
||||
'conversationId': conversationId,
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'device': device ?? defaultDevice,
|
||||
'platform': 'ANDROID',
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'osVersion': osVersion ?? defaultOsVersion,
|
||||
},
|
||||
);
|
||||
return Ws2Config(uri: uri, userId: userId);
|
||||
}
|
||||
|
||||
/// Исходящий звонок или вход в конференцию: `endpoint` из ответа opcode 78 /
|
||||
/// 166 уже содержит токен и conversationId/userId в query — дописываем
|
||||
/// клиентские параметры. Без `tgt=start` медиасервер принимает сокет, но не
|
||||
/// поднимает разговор и не шлёт нотификацию `connection`.
|
||||
factory Ws2Config.fromEndpoint(
|
||||
String endpoint, {
|
||||
required int userId,
|
||||
String capabilities = defaultCapabilities,
|
||||
String? device,
|
||||
String? osVersion,
|
||||
}) {
|
||||
final base = Uri.parse(endpoint);
|
||||
final uri = base.replace(
|
||||
queryParameters: {
|
||||
...base.queryParameters,
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'device': device ?? defaultDevice,
|
||||
'platform': 'ANDROID',
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'osVersion': osVersion ?? defaultOsVersion,
|
||||
'tgt': 'start',
|
||||
},
|
||||
);
|
||||
return Ws2Config(uri: uri, userId: userId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ошибка, которую вернул сервер в ответе на команду.
|
||||
class Ws2CommandException implements Exception {
|
||||
final String command;
|
||||
final Object? error;
|
||||
Ws2CommandException(this.command, this.error);
|
||||
@override
|
||||
String toString() => 'Ws2CommandException($command): $error';
|
||||
}
|
||||
|
||||
/// Клиент сигналинга звонка поверх WebSocket `ws2`.
|
||||
///
|
||||
/// Тонкий адаптер над Rust-ядром (kusoft [kb.CallSignaling]): ядро держит
|
||||
/// WebSocket, корреляцию `sequence`/`response`, keepalive `ping`→`pong` и
|
||||
/// разбор кадров; здесь — прежний Dart-интерфейс для [call_session].
|
||||
///
|
||||
/// Конверт сообщений:
|
||||
/// - запрос: `{"command": ..., ..., "sequence": N}`
|
||||
/// - ответ: `{"sequence": N, "response": "<command>", "type": "response"}`
|
||||
/// - пуш: `{..., "notification": "<name>", "type": "notification"}`
|
||||
class Ws2Signaling {
|
||||
final Ws2Config config;
|
||||
|
||||
WebSocket? _call;
|
||||
StreamSubscription<dynamic>? _notifSub;
|
||||
int _sequence = 0;
|
||||
final _requests = <int, Completer<Map<String, dynamic>>>{};
|
||||
final _earlyNotifications = <Map<String, dynamic>>[];
|
||||
|
||||
final _notifications = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _closed = Completer<Object?>();
|
||||
|
||||
Ws2Signaling(this.config) {
|
||||
_notifications.onListen = () {
|
||||
for (final item in _earlyNotifications) {
|
||||
_notifications.add(item);
|
||||
}
|
||||
_earlyNotifications.clear();
|
||||
};
|
||||
}
|
||||
|
||||
/// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`.
|
||||
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
|
||||
|
||||
/// Завершается, когда сокет закрыт.
|
||||
Future<Object?> get done => _closed.future;
|
||||
|
||||
bool get isConnected => _call?.readyState == WebSocket.open;
|
||||
|
||||
Future<void> connect() async {
|
||||
final call = await IkarNetwork.connectWebSocket(
|
||||
config.uri.toString(),
|
||||
headers: {'User-Agent': 'IKAR/1.0.0'},
|
||||
);
|
||||
_call = call;
|
||||
_notifSub = call.listen(
|
||||
(frame) {
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(
|
||||
frame is String ? frame : utf8.decode(frame as List<int>),
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
if (decoded['type'] == 'response') {
|
||||
final waiter = _requests.remove(
|
||||
(decoded['sequence'] as num?)?.toInt(),
|
||||
);
|
||||
if (waiter != null && !waiter.isCompleted) waiter.complete(decoded);
|
||||
} else if (decoded['type'] == 'notification') {
|
||||
if (_notifications.hasListener) {
|
||||
_notifications.add(decoded);
|
||||
} else if (_earlyNotifications.length < 256) {
|
||||
_earlyNotifications.add(decoded);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (_) => _onDone(null),
|
||||
onDone: () => _onDone(null),
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDone(Object? error) {
|
||||
for (final waiter in _requests.values) {
|
||||
if (!waiter.isCompleted)
|
||||
waiter.completeError(error ?? StateError('Звонок завершён'));
|
||||
}
|
||||
_requests.clear();
|
||||
if (!_closed.isCompleted) _closed.complete(error);
|
||||
if (!_notifications.isClosed) _notifications.close();
|
||||
}
|
||||
|
||||
/// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException],
|
||||
/// если сервер вернул ошибку.
|
||||
Future<Map<String, dynamic>> sendCommand(
|
||||
String command, {
|
||||
Map<String, dynamic> extra = const {},
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
final call = _call;
|
||||
if (call == null) {
|
||||
return Future.error(StateError('ws2 не подключён'));
|
||||
}
|
||||
try {
|
||||
final sequence = ++_sequence;
|
||||
final waiter = Completer<Map<String, dynamic>>();
|
||||
_requests[sequence] = waiter;
|
||||
call.add(
|
||||
jsonEncode({
|
||||
'type': 'request',
|
||||
'command': command,
|
||||
'sequence': sequence,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return await waiter.future.timeout(timeout);
|
||||
} finally {
|
||||
_requests.remove(sequence);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Ws2CommandException(command, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Передаёт SDP (offer/answer) другому участнику.
|
||||
Future<void> transmitSdp({
|
||||
required int participantId,
|
||||
required String type,
|
||||
required String sdp,
|
||||
String participantType = 'USER',
|
||||
int deviceIdx = 0,
|
||||
String capabilities = Ws2Config.defaultCapabilities,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'transmit-data',
|
||||
extra: {
|
||||
'participantId': participantId,
|
||||
'participantType': participantType,
|
||||
'deviceIdx': deviceIdx,
|
||||
'data': {
|
||||
'sdp': {'type': type, 'sdp': sdp},
|
||||
},
|
||||
'capabilities': capabilities,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Передаёт ICE-кандидата другому участнику (trickle).
|
||||
Future<void> transmitCandidate({
|
||||
required int participantId,
|
||||
required String candidate,
|
||||
required String sdpMid,
|
||||
required int sdpMLineIndex,
|
||||
String participantType = 'USER',
|
||||
int deviceIdx = 0,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'transmit-data',
|
||||
extra: {
|
||||
'participantId': participantId,
|
||||
'participantType': participantType,
|
||||
'deviceIdx': deviceIdx,
|
||||
'data': {
|
||||
'candidate': {
|
||||
'candidate': candidate,
|
||||
'sdpMid': sdpMid,
|
||||
'sdpMLineIndex': sdpMLineIndex,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> changeMediaSettings({
|
||||
bool isAudioEnabled = true,
|
||||
bool isVideoEnabled = false,
|
||||
bool isScreenSharingEnabled = false,
|
||||
bool isAnimojiEnabled = false,
|
||||
bool? isFastScreenSharingEnabled,
|
||||
bool? isAudioSharingEnabled,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'change-media-settings',
|
||||
extra: {
|
||||
'mediaSettings': {
|
||||
'isVideoEnabled': isVideoEnabled,
|
||||
'isAudioEnabled': isAudioEnabled,
|
||||
'isScreenSharingEnabled': isScreenSharingEnabled,
|
||||
'isAnimojiEnabled': isAnimojiEnabled,
|
||||
'isFastScreenSharingEnabled': ?isFastScreenSharingEnabled,
|
||||
'isAudioSharingEnabled': ?isAudioSharingEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> switchTopology({
|
||||
String topology = 'SERVER',
|
||||
bool force = false,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'switch-topology',
|
||||
extra: {'topology': topology, 'force': force},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> requestRealloc() => sendCommand('request-realloc');
|
||||
|
||||
/// Принять входящий звонок (сторона вызываемого).
|
||||
Future<void> acceptCall({
|
||||
bool isAudioEnabled = true,
|
||||
bool isVideoEnabled = false,
|
||||
bool isScreenSharingEnabled = false,
|
||||
bool isAnimojiEnabled = false,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'accept-call',
|
||||
extra: {
|
||||
'mediaSettings': {
|
||||
'isVideoEnabled': isVideoEnabled,
|
||||
'isAudioEnabled': isAudioEnabled,
|
||||
'isScreenSharingEnabled': isScreenSharingEnabled,
|
||||
'isAnimojiEnabled': isAnimojiEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> hangup({String reason = 'HUNGUP'}) =>
|
||||
sendCommand('hangup', extra: {'reason': reason});
|
||||
|
||||
Future<Map<String, dynamic>> allocateConsumer() => sendCommand(
|
||||
'allocate-consumer',
|
||||
extra: const {
|
||||
'capabilities': {
|
||||
'maxH264Decoders': 10,
|
||||
'producerNotificationDataChannelVersion': 7,
|
||||
'producerCommandDataChannelVersion': 2,
|
||||
'audioMix': true,
|
||||
'consumerUpdate': true,
|
||||
'onDemandTracks': true,
|
||||
'singleSession': true,
|
||||
'unifiedPlan': true,
|
||||
'fastScreenShare': true,
|
||||
'consumerFastScreenShareQualityOnDemand': true,
|
||||
'red': true,
|
||||
'videoTracksCount': 10,
|
||||
'csrcAccessible': true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>> acceptProducer({
|
||||
required String description,
|
||||
required List<String> ssrcs,
|
||||
Object? sessionId,
|
||||
}) => sendCommand(
|
||||
'accept-producer',
|
||||
extra: {
|
||||
'description': description,
|
||||
if (ssrcs.isNotEmpty) 'ssrcs': ssrcs,
|
||||
'sessionId': ?sessionId,
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> close() async {
|
||||
await _notifSub?.cancel();
|
||||
_notifSub = null;
|
||||
await _call?.close();
|
||||
_call = null;
|
||||
_onDone(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppAccent {
|
||||
static const prefKey = 'app_accent_seed';
|
||||
|
||||
static const List<({String label, Color? seed})> presets = [
|
||||
(label: 'Системный', seed: null),
|
||||
(label: 'Сиреневый', seed: Color(0xFFC1C4FF)),
|
||||
(label: 'Синий', seed: Color(0xFF4F8EFF)),
|
||||
(label: 'Бирюзовый', seed: Color(0xFF00BFA5)),
|
||||
(label: 'Зелёный', seed: Color(0xFF43A047)),
|
||||
(label: 'Янтарный', seed: Color(0xFFFFB300)),
|
||||
(label: 'Розовый', seed: Color(0xFFE91E63)),
|
||||
(label: 'Красный', seed: Color(0xFFE53935)),
|
||||
(label: 'Фиолетовый', seed: Color(0xFF7E57C2)),
|
||||
];
|
||||
|
||||
static Future<Color?> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getInt(prefKey);
|
||||
if (val == null) return null;
|
||||
return Color(val);
|
||||
}
|
||||
|
||||
static Future<void> save(Color? color) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (color == null) {
|
||||
await prefs.remove(prefKey);
|
||||
} else {
|
||||
await prefs.setInt(prefKey, color.toARGB32());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppAmoled {
|
||||
static const prefKey = 'app_amoled';
|
||||
|
||||
static bool get systemIsDark =>
|
||||
PlatformDispatcher.instance.platformBrightness == Brightness.dark;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: systemIsDark,
|
||||
read: (prefs, key) => prefs.getBool(key) ?? systemIsDark,
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
class AppAnimations {
|
||||
const AppAnimations._();
|
||||
|
||||
static const String _dir = 'assets/lottie';
|
||||
|
||||
static const String clock = '$_dir/ic_clock.json';
|
||||
static const String search = '$_dir/ic_search.json';
|
||||
static const String settings = '$_dir/ic_settings.json';
|
||||
static const String chat = '$_dir/ic_chat.json';
|
||||
static const String call = '$_dir/ic_call.json';
|
||||
static const String contacts = '$_dir/ic_contacts.json';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppBadge {
|
||||
static final enabled = _flag('badge_enabled', true);
|
||||
static final includeMuted = _flag('badge_include_muted', false);
|
||||
static final countMessages = _flag('badge_count_messages', true);
|
||||
|
||||
static Future<void> load() =>
|
||||
Future.wait([enabled.load(), includeMuted.load(), countMessages.load()]);
|
||||
|
||||
static PersistedSetting<bool> _flag(String key, bool fallback) =>
|
||||
PersistedSetting<bool>(
|
||||
prefKey: key,
|
||||
defaultValue: fallback,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum BubbleBehavior { mutable, immutable }
|
||||
|
||||
class AppBubbleBehavior {
|
||||
static const prefKey = 'app_bubble_behavior';
|
||||
|
||||
static final _setting = PersistedEnum<BubbleBehavior>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: BubbleBehavior.mutable,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<BubbleBehavior> get current => _setting.current;
|
||||
|
||||
static Future<BubbleBehavior> load() => _setting.load();
|
||||
|
||||
static Future<void> save(BubbleBehavior behavior) => _setting.save(behavior);
|
||||
|
||||
static BubbleBehavior _parse(String? val) =>
|
||||
enumFromName(BubbleBehavior.values, val, BubbleBehavior.mutable);
|
||||
|
||||
static String label(BubbleBehavior behavior) {
|
||||
switch (behavior) {
|
||||
case BubbleBehavior.mutable:
|
||||
return 'Изменяемая';
|
||||
case BubbleBehavior.immutable:
|
||||
return 'Неизменяемая';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum BubbleStyle { mobile, desktop }
|
||||
|
||||
class AppBubbleShape {
|
||||
static const prefKey = 'app_bubble_shape';
|
||||
|
||||
static final _setting = PersistedEnum<BubbleStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: BubbleStyle.mobile,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<BubbleStyle> get current => _setting.current;
|
||||
|
||||
static Future<BubbleStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(BubbleStyle style) => _setting.save(style);
|
||||
|
||||
static BubbleStyle _parse(String? val) =>
|
||||
enumFromName(BubbleStyle.values, val, BubbleStyle.mobile);
|
||||
|
||||
static String label(BubbleStyle style) {
|
||||
switch (style) {
|
||||
case BubbleStyle.mobile:
|
||||
return 'TG Mobile';
|
||||
case BubbleStyle.desktop:
|
||||
return 'TG Desktop';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppCacheExtent {
|
||||
static const prefKey = 'app_cache_extent';
|
||||
static const double defaultValue = 5000;
|
||||
static const double min = 1000;
|
||||
static const double max = 10000;
|
||||
static const double lowWarnThreshold = 2500;
|
||||
static const double highWarnThreshold = 7000;
|
||||
|
||||
static final _setting = PersistedSetting<double>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getDouble(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setDouble(key, value);
|
||||
},
|
||||
sanitize: clamp,
|
||||
);
|
||||
|
||||
static ValueNotifier<double> get current => _setting.current;
|
||||
|
||||
static Future<double> load() => _setting.load();
|
||||
|
||||
static Future<void> save(double value) => _setting.save(value);
|
||||
|
||||
static double clamp(double v) {
|
||||
if (v < min) return min;
|
||||
if (v > max) return max;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppCamera {
|
||||
static const prefKey = 'media_camera_id';
|
||||
|
||||
static final _setting = PersistedSetting<String>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: '',
|
||||
read: (prefs, key) => prefs.getString(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setString(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<String> get current => _setting.current;
|
||||
|
||||
static String? get deviceId =>
|
||||
_setting.current.value.isEmpty ? null : _setting.current.value;
|
||||
|
||||
static Future<String> load() => _setting.load();
|
||||
|
||||
static Future<void> save(String value) => _setting.save(value);
|
||||
}
|
||||
|
||||
class AppVideoNoteCamera {
|
||||
static final _custom = PersistedSetting<bool>(
|
||||
prefKey: 'video_note_custom_camera',
|
||||
defaultValue: false,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get useCustom => _custom.current;
|
||||
|
||||
static String? get customCameraId =>
|
||||
_custom.current.value ? AppCamera.deviceId : null;
|
||||
|
||||
static Future<bool> load() => _custom.load();
|
||||
|
||||
static Future<void> setUseCustom(bool value) => _custom.save(value);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../frontend/widgets/liquid_glass.dart';
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum ChatChromeStyle { color, blur, none, transparent, liquidGlass }
|
||||
|
||||
class ChatChromeMaterial {
|
||||
static bool isLiquid(ChatChromeStyle style) =>
|
||||
style == ChatChromeStyle.liquidGlass && LiquidGlass.isSupported;
|
||||
}
|
||||
|
||||
class AppChatChrome {
|
||||
static const prefKey = 'app_chat_chrome';
|
||||
|
||||
static final _setting = PersistedEnum<ChatChromeStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ChatChromeStyle.none,
|
||||
encode: _encode,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<ChatChromeStyle> get current => _setting.current;
|
||||
|
||||
static ChatChromeStyle _parse(String? value) =>
|
||||
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none);
|
||||
|
||||
static String _encode(ChatChromeStyle value) => value.name;
|
||||
|
||||
static Future<ChatChromeStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(ChatChromeStyle value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/seed_scheme.dart';
|
||||
|
||||
extension AppColorTokens on ColorScheme {
|
||||
Color get mutedText => onSurfaceVariant.withValues(alpha: 0.6);
|
||||
}
|
||||
|
||||
const int kAvatarThumbSize = 144;
|
||||
|
||||
const Color kSuccessGreen = Color(0xFF2EC36B);
|
||||
const Color kDangerRed = Color(0xFFE5484D);
|
||||
const Color kReadReceiptBlue = Color(0xFF4FC3F7);
|
||||
|
||||
class MediaAccent {
|
||||
static Color? _seed;
|
||||
static ColorScheme? _scheme;
|
||||
|
||||
static ColorScheme schemeOf(BuildContext context) {
|
||||
final seed = Theme.of(context).colorScheme.primary;
|
||||
if (_seed != seed || _scheme == null) {
|
||||
_seed = seed;
|
||||
_scheme = schemeFromSeed(seed, Brightness.dark);
|
||||
}
|
||||
return _scheme!;
|
||||
}
|
||||
|
||||
static Color of(BuildContext context) => schemeOf(context).primary;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppCommands {
|
||||
static const prefKey = 'dev_commands';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../frontend/widgets/liquid_glass.dart';
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum ComposerBackground { standard, frostBlur, liquidGlass }
|
||||
|
||||
class ComposerMaterial {
|
||||
static bool isLiquid(ComposerBackground value) =>
|
||||
value == ComposerBackground.liquidGlass && LiquidGlass.isSupported;
|
||||
|
||||
static bool isFrost(ComposerBackground value) =>
|
||||
value == ComposerBackground.frostBlur ||
|
||||
(value == ComposerBackground.liquidGlass && !LiquidGlass.isSupported);
|
||||
}
|
||||
|
||||
class AppComposerBackground {
|
||||
static const prefKey = 'app_composer_background';
|
||||
|
||||
static final _setting = PersistedEnum<ComposerBackground>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ComposerBackground.standard,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<ComposerBackground> get current => _setting.current;
|
||||
|
||||
static Future<ComposerBackground> load() => _setting.load();
|
||||
|
||||
static Future<void> save(ComposerBackground value) => _setting.save(value);
|
||||
|
||||
static ComposerBackground _parse(String? val) =>
|
||||
enumFromName(ComposerBackground.values, val, ComposerBackground.standard);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'app_visual_style.dart';
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum ComposerStyle { auto, glossy, materialYou }
|
||||
|
||||
class ComposerChrome {
|
||||
static bool isGlossy(ComposerStyle style) => switch (style) {
|
||||
ComposerStyle.auto => AppVisualStyle.current.value.glossyChrome,
|
||||
ComposerStyle.glossy => true,
|
||||
ComposerStyle.materialYou => false,
|
||||
};
|
||||
}
|
||||
|
||||
class AppComposerStyle {
|
||||
static const prefKey = 'app_composer_style';
|
||||
|
||||
static final _setting = PersistedEnum<ComposerStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ComposerStyle.glossy,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<ComposerStyle> get current => _setting.current;
|
||||
|
||||
static Future<ComposerStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(ComposerStyle value) => _setting.save(value);
|
||||
|
||||
static ComposerStyle _parse(String? val) =>
|
||||
enumFromName(ComposerStyle.values, val, ComposerStyle.glossy);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppDigitalIdNative {
|
||||
static const prefKey = 'app_digital_id_native';
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: false,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'custom_font_service.dart';
|
||||
|
||||
const String kDisplayFontFamily = 'Outfit';
|
||||
|
||||
@immutable
|
||||
class AppDisplayFont extends ThemeExtension<AppDisplayFont> {
|
||||
final String? family;
|
||||
|
||||
const AppDisplayFont(this.family);
|
||||
|
||||
@override
|
||||
AppDisplayFont copyWith({String? family}) =>
|
||||
AppDisplayFont(family ?? this.family);
|
||||
|
||||
@override
|
||||
AppDisplayFont lerp(ThemeExtension<AppDisplayFont>? other, double t) =>
|
||||
t < 0.5 ? this : (other as AppDisplayFont? ?? this);
|
||||
}
|
||||
|
||||
String? displayFontOf(BuildContext context) =>
|
||||
Theme.of(context).extension<AppDisplayFont>()?.family ?? kDisplayFontFamily;
|
||||
|
||||
class AppFont {
|
||||
final String id;
|
||||
final String label;
|
||||
final String? fontFamily;
|
||||
|
||||
final double metricScale;
|
||||
|
||||
const AppFont({
|
||||
required this.id,
|
||||
required this.label,
|
||||
this.fontFamily,
|
||||
this.metricScale = 1.0,
|
||||
});
|
||||
|
||||
bool get isSystem => fontFamily == null;
|
||||
bool get isCustom => id.startsWith(AppFonts.customPrefix);
|
||||
}
|
||||
|
||||
class AppFonts {
|
||||
static const String prefKey = 'app_font';
|
||||
static const String scalePrefKey = 'app_font_scale';
|
||||
static const String customPrefix = 'g:';
|
||||
|
||||
static const double minScale = 0.60;
|
||||
static const double maxScale = 1.35;
|
||||
static const double defaultScale = 1.0;
|
||||
|
||||
static const List<AppFont> builtIn = [
|
||||
AppFont(id: 'system', label: 'Системный'),
|
||||
AppFont(
|
||||
id: 'inter',
|
||||
label: 'Inter',
|
||||
fontFamily: 'Inter',
|
||||
metricScale: 0.967,
|
||||
),
|
||||
AppFont(
|
||||
id: 'unbounded',
|
||||
label: 'Unbounded',
|
||||
fontFamily: 'Unbounded',
|
||||
metricScale: 0.933,
|
||||
),
|
||||
];
|
||||
|
||||
static AppFont get fallback => builtIn.first;
|
||||
|
||||
static String customId(String family) => '$customPrefix$family';
|
||||
|
||||
static AppFont resolve(String id) {
|
||||
if (id.startsWith(customPrefix)) {
|
||||
final family = id.substring(customPrefix.length);
|
||||
return AppFont(
|
||||
id: id,
|
||||
label: family,
|
||||
fontFamily: family,
|
||||
metricScale: CustomFontService.metricScaleFor(family),
|
||||
);
|
||||
}
|
||||
return builtIn.firstWhere((f) => f.id == id, orElse: () => fallback);
|
||||
}
|
||||
|
||||
static double effectiveScale(String id, double userScale) =>
|
||||
userScale * resolve(id).metricScale;
|
||||
|
||||
static String? displayFamily(String id) {
|
||||
final font = resolve(id);
|
||||
return font.isSystem ? kDisplayFontFamily : font.fontFamily;
|
||||
}
|
||||
|
||||
static TextTheme textTheme(String id, TextTheme base) {
|
||||
final family = resolve(id).fontFamily;
|
||||
if (family == null) return base;
|
||||
return base.apply(fontFamily: family);
|
||||
}
|
||||
|
||||
static TextStyle sample(String id, {required double fontSize}) {
|
||||
final family = resolve(id).fontFamily;
|
||||
return TextStyle(fontFamily: family, fontSize: fontSize);
|
||||
}
|
||||
|
||||
static double clampScale(double scale) =>
|
||||
scale.clamp(minScale, maxScale).toDouble();
|
||||
|
||||
static String? familyFromInput(String input) {
|
||||
var value = input.trim();
|
||||
if (value.isEmpty) return null;
|
||||
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri != null && uri.host.contains('fonts.google.com')) {
|
||||
final idx = uri.pathSegments.indexOf('specimen');
|
||||
if (idx != -1 && idx + 1 < uri.pathSegments.length) {
|
||||
value = uri.pathSegments[idx + 1];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
value = Uri.decodeComponent(value);
|
||||
} catch (_) {}
|
||||
value = value.replaceAll('+', ' ').trim();
|
||||
return value.isEmpty ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppFrost {
|
||||
static const double sigma = 34;
|
||||
static const double panelSigma = 24;
|
||||
static const double overlaySigma = 18;
|
||||
static const double mediaBackdropSigma = 30;
|
||||
static const double glassAlpha = 0.28;
|
||||
static const double blurPanelAlpha = 0.55;
|
||||
static const double scrimAlpha = 0.4;
|
||||
|
||||
static Color glassTint(ColorScheme cs, [double alpha = glassAlpha]) =>
|
||||
cs.surfaceContainerHigh.withValues(alpha: alpha);
|
||||
|
||||
static Color blurPanelTint(ColorScheme cs) => glassTint(cs, blurPanelAlpha);
|
||||
|
||||
static Color scrim([double alpha = scrimAlpha]) =>
|
||||
Colors.black.withValues(alpha: alpha);
|
||||
|
||||
static BorderSide hairline(ColorScheme cs) =>
|
||||
BorderSide(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum AppIcon {
|
||||
defaultIcon(
|
||||
'default',
|
||||
'Default',
|
||||
'assets/ikar_icon.png',
|
||||
'MainActivity',
|
||||
null,
|
||||
),
|
||||
minimal(
|
||||
'minimal',
|
||||
'Minimal',
|
||||
'assets/meteor_icon.png',
|
||||
'MinimalIcon',
|
||||
'MinimalIcon',
|
||||
);
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String previewAsset;
|
||||
final String androidAlias;
|
||||
final String? iosAlternateName;
|
||||
|
||||
const AppIcon(
|
||||
this.id,
|
||||
this.title,
|
||||
this.previewAsset,
|
||||
this.androidAlias,
|
||||
this.iosAlternateName,
|
||||
);
|
||||
}
|
||||
|
||||
class AppIconConfig {
|
||||
static const prefKey = 'app_icon';
|
||||
static const _channel = MethodChannel('ru.ikar.app/app_icon');
|
||||
|
||||
static final ValueNotifier<AppIcon> current = ValueNotifier(
|
||||
AppIcon.defaultIcon,
|
||||
);
|
||||
|
||||
static bool get isSupported => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static Future<void> load() async {
|
||||
if (!isSupported) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var icon = _parse(prefs.getString(prefKey));
|
||||
final applied = await _appliedIcon();
|
||||
if (applied != null && applied != icon) {
|
||||
icon = applied;
|
||||
await prefs.setString(prefKey, icon.id);
|
||||
}
|
||||
current.value = icon;
|
||||
}
|
||||
|
||||
static Future<void> apply(AppIcon icon) async {
|
||||
if (!isSupported) return;
|
||||
if (current.value == icon) return;
|
||||
await _channel.invokeMethod<void>('setAppIcon', {
|
||||
'name': Platform.isIOS ? icon.iosAlternateName : icon.androidAlias,
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, icon.id);
|
||||
current.value = icon;
|
||||
}
|
||||
|
||||
static Future<AppIcon?> _appliedIcon() async {
|
||||
if (!Platform.isIOS) return null;
|
||||
try {
|
||||
final name = await _channel.invokeMethod<String>('getAppIcon');
|
||||
for (final icon in AppIcon.values) {
|
||||
if (icon.iosAlternateName == name) return icon;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static AppIcon _parse(String? val) {
|
||||
for (final icon in AppIcon.values) {
|
||||
if (icon.id == val) return icon;
|
||||
}
|
||||
return AppIcon.defaultIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppLinkPreview {
|
||||
static const prefKey = 'dev_link_preview';
|
||||
static const bool defaultValue = true;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppLiquidGlass {
|
||||
static const bool enabled = false;
|
||||
|
||||
static const double blurSigma = 0;
|
||||
static const double spread = 1;
|
||||
static const double refraction = 34;
|
||||
static const double chroma = 0;
|
||||
static const double specular = 0.45;
|
||||
static const double rimWidth = 8;
|
||||
static const Offset light = Offset(-0.4, -1);
|
||||
static const double tintFeather = 44;
|
||||
|
||||
static Color navTint(ColorScheme cs) =>
|
||||
cs.surfaceContainerHigh.withValues(alpha: 0);
|
||||
|
||||
static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.24);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppMediaCacheLimit {
|
||||
static const prefKey = 'media_cache_limit_bytes';
|
||||
static const int defaultValue = 500 * 1024 * 1024; // 500 МБ
|
||||
|
||||
/// Значение «без лимита» — вытеснение из кэша отключено.
|
||||
static const int unlimited = 0;
|
||||
|
||||
/// Доступные пресеты лимита, байты (0 — без лимита).
|
||||
static const List<int> presets = [
|
||||
100 * 1024 * 1024,
|
||||
250 * 1024 * 1024,
|
||||
500 * 1024 * 1024,
|
||||
1024 * 1024 * 1024,
|
||||
2 * 1024 * 1024 * 1024,
|
||||
unlimited,
|
||||
];
|
||||
|
||||
static final _setting = PersistedSetting<int>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getInt(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setInt(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<int> get current => _setting.current;
|
||||
|
||||
static Future<int> load() => _setting.load();
|
||||
|
||||
static Future<void> save(int value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'ios_release.dart';
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum MessageActionsStyle { radial, list }
|
||||
|
||||
class AppMessageActionsStyle {
|
||||
static const prefKey = 'app_message_actions_style';
|
||||
|
||||
static final _setting = PersistedEnum<MessageActionsStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: MessageActionsStyle.list,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<MessageActionsStyle> get current => _setting.current;
|
||||
|
||||
/// Style the menu opens with. iOS ignores a stored radial choice and
|
||||
/// always uses the list.
|
||||
static MessageActionsStyle get effective =>
|
||||
IosRelease.messageActionsStyleChoice
|
||||
? current.value
|
||||
: MessageActionsStyle.list;
|
||||
|
||||
static Future<MessageActionsStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(MessageActionsStyle style) => _setting.save(style);
|
||||
|
||||
static MessageActionsStyle _parse(String? val) =>
|
||||
enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.list);
|
||||
|
||||
static String label(MessageActionsStyle style) {
|
||||
switch (style) {
|
||||
case MessageActionsStyle.radial:
|
||||
return 'Радиальное';
|
||||
case MessageActionsStyle.list:
|
||||
return 'Список';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppMessageStatusSize {
|
||||
static const prefKey = 'app_message_status_scale';
|
||||
static const double defaultScale = 1;
|
||||
static const double minScale = 1;
|
||||
static const double maxScale = 1.5;
|
||||
|
||||
static final _setting = PersistedSetting<double>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultScale,
|
||||
read: (prefs, key) => prefs.getDouble(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setDouble(key, value);
|
||||
},
|
||||
sanitize: clampScale,
|
||||
);
|
||||
|
||||
static ValueNotifier<double> get current => _setting.current;
|
||||
|
||||
static double clampScale(double value) => value.isFinite
|
||||
? value.clamp(minScale, maxScale).toDouble()
|
||||
: defaultScale;
|
||||
|
||||
static Future<double> load() => _setting.load();
|
||||
|
||||
static Future<void> save(double value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppMicrophone {
|
||||
static const prefKey = 'call_microphone_id';
|
||||
static const String defaultValue = '';
|
||||
|
||||
static final _setting = PersistedSetting<String>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getString(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setString(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<String> get current => _setting.current;
|
||||
|
||||
static String? get deviceId =>
|
||||
_setting.current.value.isEmpty ? null : _setting.current.value;
|
||||
|
||||
static Future<String> load() => _setting.load();
|
||||
|
||||
static Future<void> save(String value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../frontend/widgets/liquid_glass.dart';
|
||||
import 'app_visual_style.dart';
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum NavPillStyle { auto, glossy, frostBlur, liquidGlass }
|
||||
|
||||
class NavPillMaterial {
|
||||
static NavPillStyle resolve(NavPillStyle style) {
|
||||
if (style != NavPillStyle.auto) return style;
|
||||
return AppVisualStyle.current.value == VisualStyle.liquidGlass
|
||||
? NavPillStyle.liquidGlass
|
||||
: NavPillStyle.glossy;
|
||||
}
|
||||
|
||||
static bool isLiquid(NavPillStyle style) =>
|
||||
resolve(style) == NavPillStyle.liquidGlass && LiquidGlass.isSupported;
|
||||
|
||||
static bool isFrost(NavPillStyle style) {
|
||||
final resolved = resolve(style);
|
||||
return resolved == NavPillStyle.frostBlur ||
|
||||
(resolved == NavPillStyle.liquidGlass && !LiquidGlass.isSupported);
|
||||
}
|
||||
}
|
||||
|
||||
class AppNavPillStyle {
|
||||
static const prefKey = 'app_nav_pill_style';
|
||||
|
||||
static final _setting = PersistedEnum<NavPillStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: NavPillStyle.glossy,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<NavPillStyle> get current => _setting.current;
|
||||
|
||||
static Future<NavPillStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(NavPillStyle value) => _setting.save(value);
|
||||
|
||||
static NavPillStyle _parse(String? val) =>
|
||||
enumFromName(NavPillStyle.values, val, NavPillStyle.glossy);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppPhonebookNames {
|
||||
static const prefKey = 'dev_phonebook_names';
|
||||
static const bool defaultValue = true;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppPillGradient {
|
||||
static const prefKey = 'app_pill_gradient';
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: false,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppPranks {
|
||||
static const prefKey = 'dev_pranks';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppPulseSource {
|
||||
static const prefKey = 'call_pulse_source';
|
||||
static const String defaultValue = '';
|
||||
|
||||
static final _setting = PersistedSetting<String>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getString(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setString(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<String> get current => _setting.current;
|
||||
|
||||
static String? get name =>
|
||||
_setting.current.value.isEmpty ? null : _setting.current.value;
|
||||
|
||||
static Future<String> load() => _setting.load();
|
||||
|
||||
static Future<void> save(String value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppShape {
|
||||
static const double card = 20;
|
||||
static const double button = 14;
|
||||
static const double sheet = 24;
|
||||
static const double dialog = 24;
|
||||
static const double pill = 100;
|
||||
|
||||
static const BorderRadius cardRadius = BorderRadius.all(
|
||||
Radius.circular(card),
|
||||
);
|
||||
static const BorderRadius buttonRadius = BorderRadius.all(
|
||||
Radius.circular(button),
|
||||
);
|
||||
static const BorderRadius pillRadius = BorderRadius.all(
|
||||
Radius.circular(pill),
|
||||
);
|
||||
|
||||
static const RoundedRectangleBorder buttonBorder = RoundedRectangleBorder(
|
||||
borderRadius: buttonRadius,
|
||||
);
|
||||
static const RoundedRectangleBorder dialogBorder = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(dialog)),
|
||||
);
|
||||
static const RoundedRectangleBorder sheetBorder = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(sheet)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppShowExtraInfo {
|
||||
static const prefKey = 'dev_show_extra_info';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppSpectrumBackground {
|
||||
static const prefKey = 'app_spectrum_background';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static bool get isEnabled => _setting.current.value;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppStories {
|
||||
static const prefKey = 'dev_stories';
|
||||
static const bool defaultValue = true;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppSwipeBackDesktop {
|
||||
static const prefKey = 'dev_swipe_back_desktop';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum AppThemeMode { system, light, dark, schedule }
|
||||
|
||||
class AppThemeModeConfig {
|
||||
static const prefKey = 'app_theme_mode';
|
||||
|
||||
static final _setting = PersistedEnum<AppThemeMode>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: AppThemeMode.system,
|
||||
encode: (mode) => mode.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<AppThemeMode> get current => _setting.current;
|
||||
|
||||
static Future<AppThemeMode> load() => _setting.load();
|
||||
|
||||
static Future<void> save(AppThemeMode mode) => _setting.save(mode);
|
||||
|
||||
static AppThemeMode _parse(String? val) =>
|
||||
enumFromName(AppThemeMode.values, val, AppThemeMode.system);
|
||||
|
||||
static String label(AppThemeMode mode) {
|
||||
switch (mode) {
|
||||
case AppThemeMode.system:
|
||||
return 'Системная';
|
||||
case AppThemeMode.light:
|
||||
return 'Светлая';
|
||||
case AppThemeMode.dark:
|
||||
return 'Тёмная';
|
||||
case AppThemeMode.schedule:
|
||||
return 'По расписанию';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/format.dart';
|
||||
|
||||
class ThemeSchedule {
|
||||
final TimeOfDay darkStart;
|
||||
final TimeOfDay darkEnd;
|
||||
|
||||
const ThemeSchedule({required this.darkStart, required this.darkEnd});
|
||||
|
||||
bool isDarkAt(DateTime now) {
|
||||
final nowMin = now.hour * 60 + now.minute;
|
||||
final startMin = darkStart.hour * 60 + darkStart.minute;
|
||||
final endMin = darkEnd.hour * 60 + darkEnd.minute;
|
||||
if (startMin == endMin) return false;
|
||||
if (startMin < endMin) {
|
||||
return nowMin >= startMin && nowMin < endMin;
|
||||
}
|
||||
return nowMin >= startMin || nowMin < endMin;
|
||||
}
|
||||
|
||||
Duration durationUntilNextSwitch(DateTime now) {
|
||||
final nowMin = now.hour * 60 + now.minute;
|
||||
final startMin = darkStart.hour * 60 + darkStart.minute;
|
||||
final endMin = darkEnd.hour * 60 + darkEnd.minute;
|
||||
int? nextMin;
|
||||
for (final m in [startMin, endMin]) {
|
||||
final delta = (m - nowMin + 1440) % 1440;
|
||||
final candidate = delta == 0 ? 1440 : delta;
|
||||
if (nextMin == null || candidate < nextMin) nextMin = candidate;
|
||||
}
|
||||
final secondsLeft = (nextMin ?? 1) * 60 - now.second;
|
||||
return Duration(seconds: secondsLeft.clamp(1, 24 * 60 * 60));
|
||||
}
|
||||
}
|
||||
|
||||
class AppThemeSchedule {
|
||||
static const prefKey = 'app_theme_schedule';
|
||||
static const _defaultStart = TimeOfDay(hour: 22, minute: 0);
|
||||
static const _defaultEnd = TimeOfDay(hour: 7, minute: 0);
|
||||
|
||||
static final ValueNotifier<ThemeSchedule> current = ValueNotifier(
|
||||
const ThemeSchedule(darkStart: _defaultStart, darkEnd: _defaultEnd),
|
||||
);
|
||||
|
||||
static Future<ThemeSchedule> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = _parse(prefs.getString(prefKey));
|
||||
current.value = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
static Future<void> save(ThemeSchedule schedule) async {
|
||||
current.value = schedule;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
prefKey,
|
||||
'${_fmt(schedule.darkStart)}-${_fmt(schedule.darkEnd)}',
|
||||
);
|
||||
}
|
||||
|
||||
static String _fmt(TimeOfDay t) => '${pad2(t.hour)}:${pad2(t.minute)}';
|
||||
|
||||
static ThemeSchedule _parse(String? val) {
|
||||
if (val == null) {
|
||||
return const ThemeSchedule(
|
||||
darkStart: _defaultStart,
|
||||
darkEnd: _defaultEnd,
|
||||
);
|
||||
}
|
||||
final parts = val.split('-');
|
||||
if (parts.length != 2) {
|
||||
return const ThemeSchedule(
|
||||
darkStart: _defaultStart,
|
||||
darkEnd: _defaultEnd,
|
||||
);
|
||||
}
|
||||
final start = _parseTime(parts[0]) ?? _defaultStart;
|
||||
final end = _parseTime(parts[1]) ?? _defaultEnd;
|
||||
return ThemeSchedule(darkStart: start, darkEnd: end);
|
||||
}
|
||||
|
||||
static TimeOfDay? _parseTime(String val) {
|
||||
final parts = val.split(':');
|
||||
if (parts.length != 2) return null;
|
||||
final h = int.tryParse(parts[0]);
|
||||
final m = int.tryParse(parts[1]);
|
||||
if (h == null || m == null) return null;
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
return TimeOfDay(hour: h, minute: m);
|
||||
}
|
||||
|
||||
static String format(TimeOfDay t) => _fmt(t);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppVideoNoteResolution {
|
||||
static const prefKey = 'dev_video_note_resolution';
|
||||
static const int defaultValue = 480;
|
||||
static const List<int> presets = [480, 720, 1080];
|
||||
|
||||
static final _setting = PersistedSetting<int>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getInt(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setInt(key, value);
|
||||
},
|
||||
sanitize: (value) => presets.contains(value) ? value : defaultValue,
|
||||
);
|
||||
|
||||
static ValueNotifier<int> get current => _setting.current;
|
||||
|
||||
static Future<int> load() => _setting.load();
|
||||
|
||||
static Future<void> save(int value) => _setting.save(value);
|
||||
}
|
||||
|
||||
class AppVideoNoteRearCamera {
|
||||
static const prefKey = 'dev_video_note_rear_camera';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
|
||||
class AppVideoNoteFps {
|
||||
static const prefKey = 'dev_video_note_fps';
|
||||
static const int defaultValue = 30;
|
||||
static const List<int> presets = [30, 60];
|
||||
|
||||
static final _setting = PersistedSetting<int>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getInt(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setInt(key, value);
|
||||
},
|
||||
sanitize: (value) => presets.contains(value) ? value : defaultValue,
|
||||
);
|
||||
|
||||
static ValueNotifier<int> get current => _setting.current;
|
||||
|
||||
static Future<int> load() => _setting.load();
|
||||
|
||||
static Future<void> save(int value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum VisualStyle { materialYou, glossy, liquidGlass }
|
||||
|
||||
extension VisualStyleChrome on VisualStyle {
|
||||
bool get glossyChrome => this != VisualStyle.materialYou;
|
||||
}
|
||||
|
||||
class AppVisualStyle {
|
||||
static const prefKey = 'app_visual_style';
|
||||
|
||||
static final _setting = PersistedEnum<VisualStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: VisualStyle.materialYou,
|
||||
encode: _encode,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<VisualStyle> get current => _setting.current;
|
||||
|
||||
static Future<VisualStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(VisualStyle value) => _setting.save(value);
|
||||
|
||||
static String _encode(VisualStyle value) => value.name;
|
||||
|
||||
static VisualStyle _parse(String? val) =>
|
||||
enumFromName(VisualStyle.values, val, VisualStyle.materialYou);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
class AppWallpaperTint {
|
||||
static const prefKey = 'app_wallpaper_tint';
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: false,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<bool> get current => _setting.current;
|
||||
|
||||
static Future<bool> load() => _setting.load();
|
||||
|
||||
static Future<void> save(bool value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/services.dart' show appFlavor;
|
||||
|
||||
// #***! что включено в сборке, всё считается на компиляции из flavor
|
||||
abstract final class BuildProfile {
|
||||
static const String storeFlavor = 'store';
|
||||
|
||||
// #***! store сборка урезана, без самообновления дев инструментов и спуфа
|
||||
static const bool isStore = appFlavor == storeFlavor;
|
||||
|
||||
static const bool plugins = true;
|
||||
|
||||
static const bool selfUpdate = !isStore;
|
||||
static const bool firebasePush = appFlavor == 'ikar';
|
||||
static const bool spoofUi = !isStore;
|
||||
static const bool tokenLogin = !isStore;
|
||||
static const bool devTools = !isStore;
|
||||
static const bool insecureTransport = !isStore;
|
||||
static const bool trafficCapture = !isStore;
|
||||
static const bool pranks = !isStore;
|
||||
static const bool hiddenContentViewers = !isStore;
|
||||
static const bool digitalId = !isStore;
|
||||
static const bool ipGeoLookup = !isStore;
|
||||
static const bool sessionCityLookup = !isStore;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
class CallNoMute {
|
||||
CallNoMute._();
|
||||
|
||||
static const String flag = '--no-mute';
|
||||
|
||||
static bool enabled = const bool.fromEnvironment('NO_MUTE');
|
||||
|
||||
static void parse(List<String> args) {
|
||||
if (args.contains(flag)) enabled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/tiled_svg.dart';
|
||||
|
||||
@immutable
|
||||
class ChatWallpaperTheme {
|
||||
final String id;
|
||||
final List<Color> colors;
|
||||
final AlignmentGeometry begin;
|
||||
final AlignmentGeometry end;
|
||||
final String? pattern;
|
||||
final Color patternColor;
|
||||
final double patternOpacity;
|
||||
final double tileSize;
|
||||
final bool dark;
|
||||
|
||||
const ChatWallpaperTheme({
|
||||
required this.id,
|
||||
required this.colors,
|
||||
this.begin = Alignment.topLeft,
|
||||
this.end = Alignment.bottomRight,
|
||||
this.pattern,
|
||||
this.patternColor = Colors.white,
|
||||
this.patternOpacity = 0.1,
|
||||
this.tileSize = 130,
|
||||
this.dark = true,
|
||||
});
|
||||
|
||||
Gradient get gradient =>
|
||||
LinearGradient(colors: colors, begin: begin, end: end);
|
||||
|
||||
Color get bubbleTint => colors.first;
|
||||
|
||||
Widget buildBackground() => _ChatWallpaperThemeView(theme: this);
|
||||
|
||||
Widget buildPreview() =>
|
||||
_ChatWallpaperThemeView(theme: this, tileScale: 0.42);
|
||||
}
|
||||
|
||||
class _ChatWallpaperThemeView extends StatelessWidget {
|
||||
final ChatWallpaperTheme theme;
|
||||
final double tileScale;
|
||||
|
||||
const _ChatWallpaperThemeView({required this.theme, this.tileScale = 1});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
DecoratedBox(decoration: BoxDecoration(gradient: theme.gradient)),
|
||||
if (theme.pattern != null)
|
||||
TiledSvgPattern(
|
||||
asset: theme.pattern!,
|
||||
color: theme.patternColor,
|
||||
opacity: theme.patternOpacity,
|
||||
tileSize: theme.tileSize * tileScale,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const String _kPatternDir = 'assets/wallpapers/patterns';
|
||||
|
||||
const List<ChatWallpaperTheme> kChatWallpaperThemes = <ChatWallpaperTheme>[
|
||||
ChatWallpaperTheme(
|
||||
id: 'ocean',
|
||||
colors: [Color(0xFF2A7B9B), Color(0xFF57C1EB), Color(0xFF246FA8)],
|
||||
pattern: '$_kPatternDir/bubbles.svg',
|
||||
patternOpacity: 0.1,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'sunset',
|
||||
colors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)],
|
||||
pattern: '$_kPatternDir/hearts.svg',
|
||||
patternOpacity: 0.12,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'lavender',
|
||||
colors: [Color(0xFF9D50BB), Color(0xFF6E48AA)],
|
||||
pattern: '$_kPatternDir/stars.svg',
|
||||
patternOpacity: 0.11,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'mint',
|
||||
colors: [Color(0xFF43E97B), Color(0xFF38F9D7)],
|
||||
pattern: '$_kPatternDir/plus.svg',
|
||||
patternColor: Colors.black,
|
||||
patternOpacity: 0.06,
|
||||
tileSize: 66,
|
||||
dark: false,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'graphite',
|
||||
colors: [Color(0xFF232526), Color(0xFF414345)],
|
||||
pattern: '$_kPatternDir/plus.svg',
|
||||
patternOpacity: 0.06,
|
||||
tileSize: 66,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'sky',
|
||||
colors: [Color(0xFF2193B0), Color(0xFF6DD5ED)],
|
||||
pattern: '$_kPatternDir/planes.svg',
|
||||
patternOpacity: 0.11,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'peach',
|
||||
colors: [Color(0xFFFFD3A5), Color(0xFFFD6585)],
|
||||
pattern: '$_kPatternDir/rings.svg',
|
||||
patternColor: Colors.black,
|
||||
patternOpacity: 0.05,
|
||||
dark: false,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'forest',
|
||||
colors: [Color(0xFF134E5E), Color(0xFF71B280)],
|
||||
pattern: '$_kPatternDir/rings.svg',
|
||||
patternOpacity: 0.09,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'grape',
|
||||
colors: [Color(0xFF4776E6), Color(0xFF8E54E9)],
|
||||
pattern: '$_kPatternDir/stars.svg',
|
||||
patternOpacity: 0.11,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'night',
|
||||
colors: [Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364)],
|
||||
pattern: '$_kPatternDir/stars.svg',
|
||||
patternOpacity: 0.08,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'rose',
|
||||
colors: [Color(0xFFF4C4F3), Color(0xFFFC67FA)],
|
||||
pattern: '$_kPatternDir/hearts.svg',
|
||||
patternOpacity: 0.14,
|
||||
),
|
||||
ChatWallpaperTheme(
|
||||
id: 'amber',
|
||||
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
|
||||
pattern: '$_kPatternDir/bubbles.svg',
|
||||
patternColor: Colors.black,
|
||||
patternOpacity: 0.05,
|
||||
dark: false,
|
||||
),
|
||||
];
|
||||
|
||||
ChatWallpaperTheme? chatWallpaperThemeById(String? id) {
|
||||
if (id == null) return null;
|
||||
for (final theme in kChatWallpaperThemes) {
|
||||
if (theme.id == id) return theme;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user