fix: большой фикс недочетов и фикс пары язв
This commit is contained in:
@@ -301,15 +301,15 @@ class Api {
|
||||
/// Стрим всех входящих пушей от сервера.
|
||||
Stream<Packet> get pushStream => _dispatcher.pushStream;
|
||||
|
||||
void dispose() {
|
||||
Future<void> dispose() async {
|
||||
_autoReconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_cleanup();
|
||||
_dispatcher.dispose();
|
||||
_connection.dispose();
|
||||
_stateController.close();
|
||||
_sessionExpiredController.close();
|
||||
_handshakeSuccessController.close();
|
||||
await _connection.dispose();
|
||||
await _stateController.close();
|
||||
await _sessionExpiredController.close();
|
||||
await _handshakeSuccessController.close();
|
||||
}
|
||||
|
||||
// Внутрянка
|
||||
|
||||
@@ -19,6 +19,11 @@ String _normalizeAuthPhone(String phone) {
|
||||
return '+$digits';
|
||||
}
|
||||
|
||||
String _maskPhone(String phone) {
|
||||
if (phone.length <= 5) return '***';
|
||||
return '${phone.substring(0, 3)}***${phone.substring(phone.length - 2)}';
|
||||
}
|
||||
|
||||
class PrivacyConfig {
|
||||
final String searchByPhone;
|
||||
final String incomingCall;
|
||||
@@ -1408,7 +1413,7 @@ class AccountModule {
|
||||
'language': language,
|
||||
};
|
||||
|
||||
logger.i('Запрос OTP-кода: phone=$normalizedPhone type=${type.value}');
|
||||
logger.i('Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}');
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.authRequest, payload);
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ class ChatsModule {
|
||||
|
||||
static StreamSubscription<Packet>? _globalPushSub;
|
||||
static StreamSubscription<SessionState>? _globalStateSub;
|
||||
static Future<void> _pushQueue = Future.value();
|
||||
|
||||
static final Set<int> _dirtyChats = {};
|
||||
static final Set<int> _knownChats = {};
|
||||
@@ -203,7 +204,7 @@ class ChatsModule {
|
||||
static void attachGlobalPushHandlers(Api api) {
|
||||
_globalPushSub?.cancel();
|
||||
_globalStateSub?.cancel();
|
||||
_globalPushSub = api.pushStream.listen(_handleGlobalPush);
|
||||
_globalPushSub = api.pushStream.listen(_enqueueGlobalPush);
|
||||
_globalStateSub = api.stateStream.listen(_handleSessionState);
|
||||
if (api.state != SessionState.online) {
|
||||
_markAllKnownChatsDirty();
|
||||
@@ -240,6 +241,14 @@ class ChatsModule {
|
||||
_dirtyChats.addAll(_knownChats);
|
||||
}
|
||||
|
||||
static void _enqueueGlobalPush(Packet packet) {
|
||||
_pushQueue = _pushQueue
|
||||
.then((_) => _handleGlobalPush(packet))
|
||||
.catchError((Object e) {
|
||||
logger.w('Ошибка обработки пуша: $e');
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _handleGlobalPush(Packet packet) async {
|
||||
switch (packet.opcode) {
|
||||
case Opcode.notifMessage:
|
||||
|
||||
@@ -286,6 +286,7 @@ class DigitalIdModule {
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
@@ -293,14 +294,23 @@ class DigitalIdModule {
|
||||
Future<String?> _storedToken() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return null;
|
||||
final value = await AppDatabase.getSyncValue(accountId, _tokenKey);
|
||||
return (value != null && value.isNotEmpty) ? value : 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 AppDatabase.setSyncValue(accountId, _tokenKey, token);
|
||||
await TokenStorage.writeSecure('${_tokenKey}_$accountId', token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../api.dart';
|
||||
import '../../core/config/proxy_config.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/transport/proxy_connector.dart';
|
||||
import '../../core/transport/tls_config.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'messages.dart';
|
||||
|
||||
@@ -158,11 +159,12 @@ class FileUploader {
|
||||
? await ProxyConnector(proxySettings).connect(uri.host, uri.port)
|
||||
: await Socket.connect(uri.host, uri.port);
|
||||
if (uri.scheme != 'https') return base;
|
||||
return SecureSocket.secure(
|
||||
base,
|
||||
host: uri.host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||
if (allowInsecure) {
|
||||
logger.w('TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM');
|
||||
return SecureSocket.secure(base, host: uri.host, onBadCertificate: (_) => true);
|
||||
}
|
||||
return SecureSocket.secure(base, host: uri.host);
|
||||
}
|
||||
|
||||
void _writeHeaders(
|
||||
|
||||
@@ -250,6 +250,18 @@ class CachedMessage {
|
||||
);
|
||||
}
|
||||
|
||||
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
|
||||
rows.map(CachedMessage.fromDbRow).toList();
|
||||
|
||||
static Future<List<CachedMessage>> fromDbRowsAsync(
|
||||
List<Map<String, dynamic>> rows,
|
||||
) {
|
||||
if (rows.length < 20) {
|
||||
return Future.value(_decodeRows(rows));
|
||||
}
|
||||
return compute(_decodeRows, rows);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
@@ -361,7 +373,7 @@ class MessagesModule {
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map(CachedMessage.fromDbRow).toList();
|
||||
return CachedMessage.fromDbRowsAsync(rows);
|
||||
}
|
||||
|
||||
CachedMessage? _parseMessage(
|
||||
|
||||
@@ -119,60 +119,57 @@ Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Распаковка пакета от сервера
|
||||
const int _isolateDecodeThreshold = 4096;
|
||||
|
||||
Future<Packet> unpackPacket(Uint8List packet) async {
|
||||
return Isolate.run(() {
|
||||
// Для удобства расшифровки пакета переводим в ByteData
|
||||
ByteData packetData = ByteData.view(
|
||||
packet.buffer,
|
||||
packet.offsetInBytes,
|
||||
packet.lengthInBytes,
|
||||
);
|
||||
final header = ByteData.sublistView(packet);
|
||||
|
||||
// API версия и cmd представляют из себя 8 битные числа
|
||||
final apiVer = packetData.getUint8(0) & 0xFF;
|
||||
final cmd = packetData.getUint8(1) & 0xFF;
|
||||
final apiVer = header.getUint8(0) & 0xFF;
|
||||
final cmd = header.getUint8(1) & 0xFF;
|
||||
final seq = header.getUint16(2) & 0xFFFF;
|
||||
final opcode = header.getUint16(4) & 0xFFFF;
|
||||
final packedLen = header.getUint32(6);
|
||||
final compFlag = packedLen >> 24;
|
||||
final payloadLength = packedLen & 0xFFFFFF;
|
||||
|
||||
// Sequence и OPCode представляют из себя 16 битные числа
|
||||
final seq = packetData.getUint16(2) & 0xFFFF;
|
||||
final opcode = packetData.getUint16(4) & 0xFFFF;
|
||||
if (payloadLength == 0) {
|
||||
return Packet(api: apiVer, cmd: cmd, seq: seq, opcode: opcode);
|
||||
}
|
||||
|
||||
// После базовых переменных идет длина пакета, является 32 битным числом
|
||||
final packedLen = packetData.getUint32(6);
|
||||
final end = headerSize + payloadLength;
|
||||
if (end > packet.length) {
|
||||
throw Exception('Packet payload length $payloadLength exceeds buffer');
|
||||
}
|
||||
final slice = Uint8List.sublistView(packet, headerSize, end);
|
||||
|
||||
// Compression flag показывает, сжат ли payload
|
||||
final compFlag = packedLen >> 24;
|
||||
dynamic payload;
|
||||
if (compFlag == 0 && slice.length < _isolateDecodeThreshold) {
|
||||
payload = _deserializePayload(slice, compFlag);
|
||||
} else {
|
||||
final owned = Uint8List.fromList(slice);
|
||||
payload = await Isolate.run(() => _deserializePayload(owned, compFlag));
|
||||
}
|
||||
|
||||
// Длина payload'а
|
||||
final payloadLength = packedLen & 0xFFFFFF;
|
||||
return Packet(
|
||||
api: apiVer,
|
||||
cmd: cmd,
|
||||
seq: seq,
|
||||
opcode: opcode,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
// Байты payload'а, могут быть сжаты LZ4
|
||||
var payloadBytes = packet.buffer.asUint8List(10, payloadLength);
|
||||
|
||||
dynamic payload;
|
||||
|
||||
if (payloadBytes.isNotEmpty) {
|
||||
if (compFlag != 0) {
|
||||
payloadBytes = _decompressPayload(payloadBytes);
|
||||
}
|
||||
|
||||
try {
|
||||
payload = msgpack.deserialize(payloadBytes);
|
||||
} catch (e) {
|
||||
if (payloadBytes.isNotEmpty) {
|
||||
throw Exception("MsgPack deserialization error: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Packet(
|
||||
api: apiVer,
|
||||
cmd: cmd,
|
||||
seq: seq,
|
||||
opcode: opcode,
|
||||
payload: payload,
|
||||
);
|
||||
});
|
||||
dynamic _deserializePayload(Uint8List payloadBytes, int compFlag) {
|
||||
var bytes = payloadBytes;
|
||||
if (compFlag != 0) {
|
||||
bytes = _decompressPayload(bytes);
|
||||
}
|
||||
if (bytes.isEmpty) return null;
|
||||
try {
|
||||
return msgpack.deserialize(bytes);
|
||||
} catch (e) {
|
||||
throw Exception('MsgPack deserialization error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Определяет формат сжатия по magic-number и распаковывает payload.
|
||||
@@ -185,7 +182,7 @@ Uint8List _decompressPayload(Uint8List src) {
|
||||
src[2] == 0x2F &&
|
||||
src[3] == 0xFD) {
|
||||
try {
|
||||
return ZstdCodec().decompress(src);
|
||||
return ZstdCodec(maxDecompressedSize: _maxDecompressedSize).decompress(src);
|
||||
} catch (e) {
|
||||
throw Exception('Zstd decompression error: $e');
|
||||
}
|
||||
@@ -215,9 +212,23 @@ Uint8List _decompressPayload(Uint8List src) {
|
||||
/// LZ4 block декомпрессия (без frame-заголовка).
|
||||
/// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает.
|
||||
Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
|
||||
final dst = BytesBuilder(copy: false);
|
||||
var out = Uint8List(1024);
|
||||
int outLen = 0;
|
||||
int pos = 0;
|
||||
|
||||
void ensure(int extra) {
|
||||
if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит');
|
||||
if (outLen + extra <= out.length) return;
|
||||
var newCap = out.length * 2;
|
||||
while (newCap < outLen + extra) {
|
||||
newCap *= 2;
|
||||
}
|
||||
if (newCap > maxSize) newCap = maxSize;
|
||||
final grown = Uint8List(newCap);
|
||||
grown.setRange(0, outLen, out);
|
||||
out = grown;
|
||||
}
|
||||
|
||||
while (pos < src.length) {
|
||||
final token = src[pos++];
|
||||
var litLen = token >> 4;
|
||||
@@ -231,7 +242,9 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
|
||||
}
|
||||
|
||||
if (litLen > 0) {
|
||||
dst.add(src.sublist(pos, pos + litLen));
|
||||
ensure(litLen);
|
||||
out.setRange(outLen, outLen + litLen, src, pos);
|
||||
outLen += litLen;
|
||||
pos += litLen;
|
||||
}
|
||||
|
||||
@@ -251,12 +264,14 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
|
||||
}
|
||||
}
|
||||
|
||||
final out = dst.toBytes();
|
||||
final start = out.length - offset;
|
||||
dst.add(List<int>.generate(matchLen, (i) => out[start + (i % offset)]));
|
||||
|
||||
if (dst.length > maxSize) throw StateError('LZ4: превышен лимит');
|
||||
ensure(matchLen);
|
||||
final start = outLen - offset;
|
||||
if (start < 0) throw StateError('LZ4: offset за пределами вывода');
|
||||
for (var i = 0; i < matchLen; i++) {
|
||||
out[outLen + i] = out[start + i];
|
||||
}
|
||||
outLen += matchLen;
|
||||
}
|
||||
|
||||
return dst.toBytes();
|
||||
return Uint8List.sublistView(out, 0, outLen);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class AppDatabase {
|
||||
final dbPath = await getDatabasesPath();
|
||||
return openDatabase(
|
||||
join(dbPath, 'komet.db'),
|
||||
version: 10,
|
||||
version: 11,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -209,6 +209,9 @@ class AppDatabase {
|
||||
'ALTER TABLE chats_cache ADD COLUMN admins TEXT',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 11) {
|
||||
await _createIndexes(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -234,6 +237,19 @@ class AppDatabase {
|
||||
await db.execute(_chatsCacheSchema);
|
||||
await db.execute(_contactsSchema);
|
||||
await db.execute(_messagesSchema);
|
||||
await _createIndexes(db);
|
||||
}
|
||||
|
||||
static Future<void> _createIndexes(Database db) async {
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(account_id, chat_id, time DESC)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_chats_account ON chats_cache(account_id, last_event_time DESC)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(account_id)',
|
||||
);
|
||||
}
|
||||
|
||||
static const _contactsSchema = '''
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class TokenStorage {
|
||||
static const _tokenPrefix = 'auth_token_';
|
||||
static const _activeAccountKey = 'active_account_id';
|
||||
|
||||
static const _secure = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
static Future<void> writeSecure(String key, String value) async {
|
||||
await _secure.write(key: key, value: value);
|
||||
}
|
||||
|
||||
static Future<String?> readSecure(String key) async {
|
||||
return _secure.read(key: key);
|
||||
}
|
||||
|
||||
static Future<void> deleteSecure(String key) async {
|
||||
await _secure.delete(key: key);
|
||||
}
|
||||
|
||||
static Future<void> saveToken(String token, int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('$_tokenPrefix$accountId', token);
|
||||
await _secure.write(key: '$_tokenPrefix$accountId', value: token);
|
||||
}
|
||||
|
||||
static Future<String?> readToken(int accountId) async {
|
||||
final key = '$_tokenPrefix$accountId';
|
||||
final secured = await _secure.read(key: key);
|
||||
if (secured != null) return secured;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('$_tokenPrefix$accountId');
|
||||
final legacy = prefs.getString(key);
|
||||
if (legacy != null) {
|
||||
await _secure.write(key: key, value: legacy);
|
||||
await prefs.remove(key);
|
||||
return legacy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> deleteToken(int accountId) async {
|
||||
final key = '$_tokenPrefix$accountId';
|
||||
await _secure.delete(key: key);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('$_tokenPrefix$accountId');
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
static Future<void> setActiveAccount(int accountId) async {
|
||||
|
||||
@@ -30,7 +30,7 @@ class Connection {
|
||||
void _setState(SocketState newState) {
|
||||
if (_state == newState) return;
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
if (!_stateController.isClosed) _stateController.add(newState);
|
||||
}
|
||||
|
||||
Future<void> connect(
|
||||
@@ -65,7 +65,9 @@ class Connection {
|
||||
logger.i('Подключено к $host:$port');
|
||||
|
||||
_subscription = _socket!.listen(
|
||||
(data) => _dataController.add(data),
|
||||
(data) {
|
||||
if (!_dataController.isClosed) _dataController.add(data);
|
||||
},
|
||||
onError: (Object error) {
|
||||
logger.e('Ошибка сокета: $error');
|
||||
disconnect();
|
||||
@@ -138,9 +140,9 @@ class Connection {
|
||||
_setState(SocketState.disconnected);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_dataController.close();
|
||||
_stateController.close();
|
||||
Future<void> dispose() async {
|
||||
await disconnect();
|
||||
await _dataController.close();
|
||||
await _stateController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ class PacketDispatcher {
|
||||
/// Регистрирует ожидание ответа — future завершится когда
|
||||
/// придёт пакет с совпадающим seq.
|
||||
Future<Packet> registerPending(int seq) {
|
||||
final existing = _pendingRequests[seq];
|
||||
if (existing != null && !existing.isCompleted) {
|
||||
existing.completeError(
|
||||
StateError('seq=$seq переиспользован до получения ответа'),
|
||||
);
|
||||
}
|
||||
final completer = Completer<Packet>();
|
||||
_pendingRequests[seq] = completer;
|
||||
_requestTimestamps[seq] = DateTime.now();
|
||||
@@ -55,7 +61,7 @@ class PacketDispatcher {
|
||||
packet.cmd == CmdType.error ||
|
||||
packet.cmd == CmdType.notFound) {
|
||||
logger.i(
|
||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}',
|
||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}',
|
||||
);
|
||||
|
||||
final completer = _pendingRequests.remove(packet.seq);
|
||||
@@ -84,7 +90,7 @@ class PacketDispatcher {
|
||||
}
|
||||
} else if (packet.isPush) {
|
||||
logger.i(
|
||||
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}',
|
||||
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}',
|
||||
);
|
||||
_pushHandlers[packet.opcode]?.call(packet);
|
||||
_pushController.add(packet);
|
||||
|
||||
@@ -18,7 +18,7 @@ class PacketSender {
|
||||
final data = packPacket(opcode, payload, seq: seq);
|
||||
connection.write(data);
|
||||
logger.i(
|
||||
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${redactForLog(payload)}}',
|
||||
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}',
|
||||
);
|
||||
return seq;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class TlsConfig {
|
||||
static const String prefKey = 'dev_tls_insecure';
|
||||
|
||||
static Future<bool> isInsecureAllowed() async {
|
||||
if (kReleaseMode) return false;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
const _redacted = '***';
|
||||
|
||||
const _sensitiveSubstrings = ['password', 'token', 'phone', 'secret'];
|
||||
const _sensitiveSubstrings = [
|
||||
'password',
|
||||
'token',
|
||||
'phone',
|
||||
'secret',
|
||||
'auth',
|
||||
];
|
||||
|
||||
const _sensitiveExact = {
|
||||
'code',
|
||||
@@ -12,6 +20,10 @@ const _sensitiveExact = {
|
||||
'qrlink',
|
||||
'text',
|
||||
'msisdn',
|
||||
'deviceid',
|
||||
'mt_instanceid',
|
||||
'instanceid',
|
||||
'webappdata',
|
||||
};
|
||||
|
||||
bool _isSensitiveKey(Object? key) {
|
||||
@@ -37,3 +49,8 @@ dynamic redactForLog(dynamic value) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Object? payloadForLog(dynamic value) {
|
||||
if (kReleaseMode) return '<payload hidden>';
|
||||
return redactForLog(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
bool get webViewSupported {
|
||||
if (kIsWeb) return true;
|
||||
return defaultTargetPlatform != TargetPlatform.linux;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import 'proxy_settings_sheet.dart';
|
||||
import 'server_settings_sheet.dart';
|
||||
import '../profile/spoof_screen.dart';
|
||||
import '../profile/debug_menu_screen.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
@@ -53,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
Future<void> _onBackPressed() async {
|
||||
final returnId = widget.returnToAccountId;
|
||||
if (returnId != null) {
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await accountModule.switchAccount(returnId);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
import '../profile/settings_tab.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
import '../../widgets/account_switcher_overlay.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
@@ -2318,6 +2319,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (!mounted) return;
|
||||
if (accountId == null) {
|
||||
final previousId = await TokenStorage.getActiveAccountId();
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await accountModule.beginAddAccount();
|
||||
} catch (_) {}
|
||||
@@ -2330,6 +2332,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
);
|
||||
return;
|
||||
}
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await accountModule.switchAccount(accountId);
|
||||
} catch (e) {
|
||||
|
||||
@@ -141,7 +141,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
Timer? _shimmerStartTimer;
|
||||
bool _historyKickedOff = false;
|
||||
List<CachedMessage> _messages = [];
|
||||
int _messagesRevision = 0;
|
||||
final ValueNotifier<int> _messagesRev = ValueNotifier(0);
|
||||
List<Object>? _combinedItemsCache;
|
||||
int? _combinedItemsKey;
|
||||
bool _floatingDateScheduled = false;
|
||||
@@ -234,7 +234,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
.toList();
|
||||
setState(() {
|
||||
_messages = first;
|
||||
_messagesRevision++;
|
||||
_messagesRev.value++;
|
||||
_isLoading = false;
|
||||
_onLoadingFinished();
|
||||
});
|
||||
@@ -303,8 +303,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
);
|
||||
if (mounted && fullRows.length > _messages.length) {
|
||||
_applyMergedMessages(fullRows);
|
||||
final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows);
|
||||
if (mounted && fullDecoded.length > _messages.length) {
|
||||
_applyMergedMessages(fullDecoded);
|
||||
}
|
||||
|
||||
if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) {
|
||||
@@ -326,8 +327,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
);
|
||||
final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows);
|
||||
if (mounted) {
|
||||
_applyMergedMessages(updatedRows, markLoaded: true);
|
||||
_applyMergedMessages(updatedDecoded, markLoaded: true);
|
||||
}
|
||||
unawaited(
|
||||
ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId),
|
||||
@@ -345,13 +347,12 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _applyMergedMessages(
|
||||
List<Map<String, dynamic>> rowsDesc, {
|
||||
List<CachedMessage> decodedDesc, {
|
||||
bool markLoaded = false,
|
||||
}) {
|
||||
final byId = <String, CachedMessage>{for (final m in _messages) m.id: m};
|
||||
final merged = <CachedMessage>[];
|
||||
for (final row in rowsDesc.reversed) {
|
||||
final fresh = CachedMessage.fromDbRow(row);
|
||||
for (final fresh in decodedDesc.reversed) {
|
||||
final old = byId[fresh.id];
|
||||
merged.add(old != null && _sameMessage(old, fresh) ? old : fresh);
|
||||
}
|
||||
@@ -361,7 +362,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
setState(() {
|
||||
if (changed) {
|
||||
_messages = merged;
|
||||
_messagesRevision++;
|
||||
_messagesRev.value++;
|
||||
}
|
||||
if (markLoaded) {
|
||||
_isLoading = false;
|
||||
@@ -437,6 +438,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
_typingTimers.clear();
|
||||
_headerStatusNotifier.dispose();
|
||||
_otherReadTime.dispose();
|
||||
_messagesRev.dispose();
|
||||
_finishPrankReveal();
|
||||
_uploadStatus.dispose();
|
||||
_attachAnim.dispose();
|
||||
@@ -594,17 +596,20 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
void _bumpMessages() {
|
||||
_combinedItemsCache = null;
|
||||
_messagesRev.value++;
|
||||
}
|
||||
|
||||
void _onMessageEvent(MessageEvent event) {
|
||||
if (!mounted) return;
|
||||
switch (event) {
|
||||
case MessageAddedEvent(:final message):
|
||||
if (message.senderId == _myId) return;
|
||||
if (_messages.any((m) => m.id == message.id)) return;
|
||||
setState(() {
|
||||
_lastSentId = message.id;
|
||||
_messages.add(message);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_lastSentId = message.id;
|
||||
_messages.add(message);
|
||||
_bumpMessages();
|
||||
_clearTyping(message.senderId);
|
||||
Haptics.tap();
|
||||
_scrollToBottom();
|
||||
@@ -612,17 +617,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
case MessageEditedEvent(:final message):
|
||||
final idx = _messages.indexWhere((m) => m.id == message.id);
|
||||
if (idx == -1) return;
|
||||
setState(() {
|
||||
_messages[idx] = message;
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[idx] = message;
|
||||
_bumpMessages();
|
||||
case MessageRemovedEvent(:final messageId):
|
||||
final idx = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (idx == -1) return;
|
||||
setState(() {
|
||||
_messages.removeAt(idx);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages.removeAt(idx);
|
||||
_bumpMessages();
|
||||
_reactionNotifiers.remove(messageId)?.dispose();
|
||||
case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
|
||||
_reactionNotifiers[messageId]?.value = reactionInfo;
|
||||
@@ -724,12 +725,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
|
||||
_hasText.value = false;
|
||||
setState(() {
|
||||
_lastSentId = tempId;
|
||||
_messages.add(tempMessage);
|
||||
_messagesRevision++;
|
||||
_messageController.clear();
|
||||
});
|
||||
_lastSentId = tempId;
|
||||
_messages.add(tempMessage);
|
||||
_messageController.clear();
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(tempMessage));
|
||||
|
||||
// Instant tactile "whoosh" the moment the message leaves the composer,
|
||||
@@ -756,10 +755,8 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
time: now,
|
||||
status: 'sent',
|
||||
);
|
||||
setState(() {
|
||||
_messages[index] = sent;
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[index] = sent;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(sent, removeId: tempId));
|
||||
}
|
||||
|
||||
@@ -785,10 +782,8 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
time: now,
|
||||
status: 'error',
|
||||
);
|
||||
setState(() {
|
||||
_messages[index] = failed;
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[index] = failed;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(failed));
|
||||
}
|
||||
}
|
||||
@@ -871,7 +866,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
if (anyChanged) {
|
||||
setState(() => _messagesRevision++);
|
||||
_bumpMessages();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -888,7 +883,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
List<Object> _buildCombinedItems() {
|
||||
final key = Object.hash(_messagesRevision, _messages.length);
|
||||
final key = Object.hash(_messagesRev.value, _messages.length);
|
||||
final cached = _combinedItemsCache;
|
||||
if (cached != null && _combinedItemsKey == key) return cached;
|
||||
|
||||
@@ -1232,6 +1227,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
Widget _buildMessagesList() {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _messagesRev,
|
||||
builder: (context, _, _) => _buildMessagesListContent(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesListContent() {
|
||||
if (_messages.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
@@ -1301,7 +1303,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
? _SentMessageAnimation(
|
||||
key: ValueKey('anim_${message.id}'),
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => _lastSentId = null);
|
||||
if (mounted) {
|
||||
_lastSentId = null;
|
||||
_bumpMessages();
|
||||
}
|
||||
},
|
||||
child: pressable,
|
||||
)
|
||||
@@ -1692,11 +1697,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
status: 'sending',
|
||||
attachments: [attachment],
|
||||
);
|
||||
setState(() {
|
||||
_lastSentId = tempId;
|
||||
_messages.add(msg);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_lastSentId = tempId;
|
||||
_messages.add(msg);
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
return tempId;
|
||||
@@ -1711,20 +1714,18 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
final old = _messages[idx];
|
||||
setState(() {
|
||||
_messages[idx] = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: old.accountId,
|
||||
chatId: old.chatId,
|
||||
senderId: old.senderId,
|
||||
text: old.text,
|
||||
time: old.time,
|
||||
status: status,
|
||||
payload: old.payload,
|
||||
attachments: attachment != null ? [attachment] : old.attachments,
|
||||
);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[idx] = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: old.accountId,
|
||||
chatId: old.chatId,
|
||||
senderId: old.senderId,
|
||||
text: old.text,
|
||||
time: old.time,
|
||||
status: status,
|
||||
payload: old.payload,
|
||||
attachments: attachment != null ? [attachment] : old.attachments,
|
||||
);
|
||||
_bumpMessages();
|
||||
}
|
||||
|
||||
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
|
||||
|
||||
@@ -159,13 +159,14 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
|
||||
if (!mounted) return;
|
||||
navigator.pop();
|
||||
pushSwipeable(
|
||||
context,
|
||||
(_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
name: chat.title ?? title,
|
||||
imageUrl: chat.iconUrl ?? '',
|
||||
chatType: chat.type,
|
||||
navigator.push(
|
||||
SwipeRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
name: chat.title ?? title,
|
||||
imageUrl: chat.iconUrl ?? '',
|
||||
chatType: chat.type,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -270,9 +270,9 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
|
||||
if (n is Map) name = n['name']?.toString();
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
final navigator = Navigator.of(context);
|
||||
navigator.pop();
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContactProfileScreen(
|
||||
contactId: id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/digital_id.dart';
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../core/utils/webview_support.dart';
|
||||
import '../../../main.dart' show digitalIdModule;
|
||||
import '../../../models/digital_id.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
@@ -89,6 +90,13 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
|
||||
Future<void> _linkGosuslugi() async {
|
||||
if (_busy) return;
|
||||
if (!webViewSupported) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Привязка Госуслуг недоступна на этой платформе. Сделайте это в приложении на телефоне.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final link = await digitalIdModule.createEsiaLink();
|
||||
@@ -173,14 +181,16 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
if (_error != null) {
|
||||
return _ErrorView(message: _error!, onRetry: _load);
|
||||
}
|
||||
if (_docs == null) {
|
||||
return _buildOnboarding(cs);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
children: [
|
||||
if (_docs != null) ..._buildProfile(cs, _docs!),
|
||||
if (_docs == null) _buildOnboarding(cs),
|
||||
..._buildProfile(cs, _docs!),
|
||||
if (_cards.isNotEmpty) ..._buildCards(cs),
|
||||
const SizedBox(height: 16),
|
||||
_buildBiometryInfo(cs),
|
||||
@@ -190,51 +200,79 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
}
|
||||
|
||||
Widget _buildOnboarding(ColorScheme cs) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.badge, size: 40, color: cs.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Цифровой ID не настроен',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.badge, size: 72, color: cs.primary),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Цифровой ID не настроен',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_needsGosuslugi
|
||||
? 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.'
|
||||
: 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_needsGosuslugi
|
||||
? 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.'
|
||||
: 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.',
|
||||
style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _linkGosuslugi,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Symbols.link),
|
||||
label: const Text('Привязать Госуслуги'),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _loadDocsExplicit,
|
||||
icon: const Icon(Symbols.sync),
|
||||
label: const Text('Загрузить документы'),
|
||||
),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _loadDocsExplicit,
|
||||
icon: const Icon(Symbols.sync, size: 18),
|
||||
label: const Text(
|
||||
'Загрузить документы',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _linkGosuslugi,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Symbols.link, size: 18),
|
||||
label: const Text(
|
||||
'Привязать Госуслуги',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildBiometryInfo(cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../main.dart' show webAppModule;
|
||||
import '../../../main.dart' show webAppModule, digitalIdModule;
|
||||
import '../../widgets/webview_permission_prompt.dart';
|
||||
|
||||
Future<void> resetDigitalIdWebData() async {
|
||||
await CookieManager.instance().deleteAllCookies();
|
||||
@@ -14,6 +15,13 @@ Future<void> resetDigitalIdWebData() async {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> resetDigitalIdSession() async {
|
||||
digitalIdModule.reset();
|
||||
try {
|
||||
await resetDigitalIdWebData();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const String _kBridge = r'''
|
||||
(function(){
|
||||
var sawOpenLink = false;
|
||||
@@ -256,12 +264,8 @@ class _DigitalIdWebScreenState extends State<DigitalIdWebScreen> {
|
||||
},
|
||||
);
|
||||
},
|
||||
onPermissionRequest: (controller, request) async {
|
||||
return PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: PermissionResponseAction.GRANT,
|
||||
);
|
||||
},
|
||||
onPermissionRequest: (controller, request) =>
|
||||
askWebViewPermission(context, request),
|
||||
shouldOverrideUrlLoading: (controller, action) async {
|
||||
final uri = action.request.url;
|
||||
final url = uri?.toString() ?? '';
|
||||
|
||||
@@ -32,10 +32,11 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
return;
|
||||
}
|
||||
final jsonStr = await AppDatabase.getLoginInfo(accountId);
|
||||
if (!mounted) return;
|
||||
if (jsonStr != null) {
|
||||
setState(() => _info = jsonDecode(jsonStr) as Map<String, dynamic>);
|
||||
}
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
setState(() => _isLoading = false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Error: $e');
|
||||
|
||||
@@ -16,7 +16,6 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
bool _isLoading = true;
|
||||
bool _is2faEnabled = false;
|
||||
bool _isAuthenticated = false;
|
||||
String? _verifiedPassword;
|
||||
TwoFactorDetails? _details;
|
||||
|
||||
final _passwordController = TextEditingController();
|
||||
@@ -47,9 +46,9 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAuthenticated = true;
|
||||
_verifiedPassword = _passwordController.text;
|
||||
_details = details;
|
||||
});
|
||||
_passwordController.clear();
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _errorMessage = 'Неверный пароль');
|
||||
} finally {
|
||||
@@ -57,6 +56,45 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptPassword() async {
|
||||
final controller = TextEditingController();
|
||||
try {
|
||||
return await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Подтвердите пароль'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Текущий пароль'),
|
||||
onSubmitted: (v) => Navigator.of(ctx).pop(v),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||
child: const Text('Продолжить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openWithPassword(Widget Function(String password) builder) async {
|
||||
final password = await _promptPassword();
|
||||
if (password == null || password.isEmpty || !mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => builder(password)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _check2faStatus() async {
|
||||
try {
|
||||
bool is2faEnabled;
|
||||
@@ -336,13 +374,8 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
icon: Symbols.password,
|
||||
label: 'Изменить пароль',
|
||||
isLast: false,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TwoFactorPasswordChangeScreen(
|
||||
currentPassword: _verifiedPassword!,
|
||||
),
|
||||
),
|
||||
onTap: () => _openWithPassword(
|
||||
(pwd) => TwoFactorPasswordChangeScreen(currentPassword: pwd),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
@@ -354,13 +387,8 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
icon: Icons.email_outlined,
|
||||
label: 'Изменить почту',
|
||||
isLast: false,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TwoFactorEmailChangeScreen(
|
||||
currentPassword: _verifiedPassword!,
|
||||
),
|
||||
),
|
||||
onTap: () => _openWithPassword(
|
||||
(pwd) => TwoFactorEmailChangeScreen(currentPassword: pwd),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
@@ -373,13 +401,8 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
label: 'Удалить пароль',
|
||||
isLast: true,
|
||||
textColor: cs.error,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TwoFactorRemoveScreen(
|
||||
currentPassword: _verifiedPassword!,
|
||||
),
|
||||
),
|
||||
onTap: () => _openWithPassword(
|
||||
(pwd) => TwoFactorRemoveScreen(currentPassword: pwd),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -541,6 +564,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
break;
|
||||
}
|
||||
final trackId = await accountModule.create2faTrack();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_trackId = trackId;
|
||||
_step = 1;
|
||||
@@ -555,12 +579,14 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
_trackId!,
|
||||
_passwordController.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 2);
|
||||
break;
|
||||
case 2:
|
||||
if (_hintController.text.isNotEmpty) {
|
||||
await accountModule.set2faHint(_trackId!, _hintController.text);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 3);
|
||||
break;
|
||||
case 3:
|
||||
@@ -573,6 +599,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
break;
|
||||
}
|
||||
await accountModule.verify2faEmail(_trackId!, _emailController.text);
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 4);
|
||||
break;
|
||||
case 4:
|
||||
@@ -585,7 +612,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) {
|
||||
_isLoading.value = false;
|
||||
@@ -976,7 +1003,7 @@ class _TwoFactorPasswordChangeScreenState
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
@@ -1144,6 +1171,7 @@ class _TwoFactorEmailChangeScreenState
|
||||
}
|
||||
final trackId = await _ensureTrack();
|
||||
await accountModule.verify2faEmail(trackId, _emailController.text);
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 1);
|
||||
break;
|
||||
case 1:
|
||||
@@ -1164,7 +1192,7 @@ class _TwoFactorEmailChangeScreenState
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
@@ -1333,7 +1361,7 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../widgets/sheet_helpers.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
import '../auth/proxy_settings_sheet.dart';
|
||||
import '../../../core/config/app_digital_id_mode.dart';
|
||||
import '../../../core/utils/webview_support.dart';
|
||||
import '../digital_id/digital_id_screen.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
import '../webapp/web_app_screen.dart';
|
||||
@@ -212,6 +213,7 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ChatsModule.resetForAccountSwitch();
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await api.connect();
|
||||
} catch (_) {}
|
||||
@@ -259,7 +261,9 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AppDigitalIdNative.current.value
|
||||
builder: (context) =>
|
||||
AppDigitalIdNative.current.value ||
|
||||
!webViewSupported
|
||||
? const DigitalIdScreen()
|
||||
: const DigitalIdWebScreen(),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../widgets/webview_permission_prompt.dart';
|
||||
|
||||
class WebAppScreen extends StatefulWidget {
|
||||
final String title;
|
||||
@@ -118,6 +119,8 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
||||
useHybridComposition: true,
|
||||
),
|
||||
onWebViewCreated: (controller) => _controller = controller,
|
||||
onPermissionRequest: (controller, request) =>
|
||||
askWebViewPermission(context, request),
|
||||
onProgressChanged: (controller, progress) {
|
||||
if (!mounted) return;
|
||||
setState(() => _progress = progress / 100);
|
||||
|
||||
@@ -11,7 +11,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) {
|
||||
);
|
||||
overlay.insert(entry);
|
||||
Future.delayed(const Duration(milliseconds: 2600), () {
|
||||
entry.remove();
|
||||
if (entry.mounted) entry.remove();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ class _BubbleCtx {
|
||||
}) : dim = text.withValues(alpha: 0.7);
|
||||
}
|
||||
|
||||
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
|
||||
final Expando<String> _clockTextCache = Expando<String>();
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
static const double photoMaxSize = 280.0;
|
||||
static const double photoMinSize = 100.0;
|
||||
@@ -129,6 +132,13 @@ class MessageBubble extends StatelessWidget {
|
||||
return BubbleShape.groupedMiddle;
|
||||
}
|
||||
|
||||
MessageType get _contentType =>
|
||||
_contentTypeCache[message] ??= _computeContentType();
|
||||
|
||||
String get _clockText =>
|
||||
_clockTextCache[message] ??=
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time));
|
||||
|
||||
MessageType _computeContentType() {
|
||||
if (message.isControl) return MessageType.control;
|
||||
final attachments = message.attachments;
|
||||
@@ -269,7 +279,7 @@ class MessageBubble extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final contentType = _computeContentType();
|
||||
final contentType = _contentType;
|
||||
|
||||
if (message.isControl) {
|
||||
const controlShape = BubbleShape.singleMiddle;
|
||||
@@ -577,8 +587,8 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
final metaWidget = Text(
|
||||
message.status == 'EDITED'
|
||||
? '${formatClock(DateTime.fromMillisecondsSinceEpoch(message.time))} ред.'
|
||||
: formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
? '$_clockText ред.'
|
||||
: _clockText,
|
||||
style: TextStyle(color: ctx.dim, fontSize: 10),
|
||||
);
|
||||
|
||||
@@ -1833,7 +1843,7 @@ class MessageBubble extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
_clockText,
|
||||
style: TextStyle(color: ctx.dim, fontSize: 11),
|
||||
),
|
||||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||||
@@ -1854,7 +1864,7 @@ class MessageBubble extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
_clockText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
@@ -2202,6 +2212,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
|
||||
TranscriptionCache.put(widget.messageId, result);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transcriptionLoading = false;
|
||||
if (result.status == 1) {
|
||||
@@ -2215,6 +2226,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transcriptionLoading = false;
|
||||
_transcriptionText = 'ошибка транскрибации';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
String _resourceLabel(PermissionResourceType type) {
|
||||
if (type == PermissionResourceType.CAMERA) return 'камера';
|
||||
if (type == PermissionResourceType.MICROPHONE) return 'микрофон';
|
||||
if (type == PermissionResourceType.CAMERA_AND_MICROPHONE) {
|
||||
return 'камера и микрофон';
|
||||
}
|
||||
if (type == PermissionResourceType.GEOLOCATION) return 'геолокация';
|
||||
return 'дополнительный доступ';
|
||||
}
|
||||
|
||||
Future<PermissionResponse> askWebViewPermission(
|
||||
BuildContext context,
|
||||
PermissionRequest request,
|
||||
) async {
|
||||
PermissionResponse deny() => PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: PermissionResponseAction.DENY,
|
||||
);
|
||||
|
||||
if (!context.mounted) return deny();
|
||||
|
||||
final labels = <String>{
|
||||
for (final r in request.resources) _resourceLabel(r),
|
||||
}.join(', ');
|
||||
final host = request.origin.host.isNotEmpty
|
||||
? request.origin.host
|
||||
: 'Веб-страница';
|
||||
|
||||
final granted = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Запрос доступа'),
|
||||
content: Text('$host запрашивает доступ к: $labels.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Запретить'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Разрешить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: granted == true
|
||||
? PermissionResponseAction.GRANT
|
||||
: PermissionResponseAction.DENY,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user