fix: большой фикс недочетов и фикс пары язв

This commit is contained in:
Jganenok
2026-06-09 01:00:04 +07:00
parent 73ad6dd1e6
commit 580e30897b
32 changed files with 590 additions and 244 deletions
+7 -1
View File
@@ -61,6 +61,12 @@
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере.</string>
<string>Камера нужна для съёмки фото и видео в чатах, сканирования QR-кода входа и работы веб-приложений.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Микрофон нужен для записи голосовых сообщений, видео и звонков.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Доступ к галерее нужен, чтобы сохранять полученные фото и видео.</string>
</dict>
</plist>
+5 -5
View File
@@ -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();
}
// Внутрянка
+6 -1
View File
@@ -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);
+10 -1
View File
@@ -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:
+13 -3
View File
@@ -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 -5
View File
@@ -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(
+13 -1
View File
@@ -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(
+71 -56
View File
@@ -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);
}
+17 -1
View File
@@ -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 = '''
+32 -4
View File
@@ -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 {
+8 -6
View File
@@ -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();
}
}
+8 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
}
+2
View File
@@ -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;
}
+18 -1
View File
@@ -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);
}
+6
View File
@@ -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) {
+59 -58
View File
@@ -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();
});
}
+17 -5
View File
@@ -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,
);
}
+56
View File
@@ -451,6 +451,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.34"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -541,6 +589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
+1
View File
@@ -55,6 +55,7 @@ dependencies:
material_symbols_icons: ^4.2906.0
dynamic_color: ^1.8.1
shared_preferences: ^2.5.4
flutter_secure_storage: ^9.2.2
package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0
cached_network_image: ^3.4.1