From 0091b5f27261a572b2b421b2d4daa367db078fab Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 22 Aug 2026 21:54:11 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=81=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=20=D0=B4=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B6=D0=BA=D0=B8=20=D0=B2=20=D0=BA=D1=80=D1=83=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=D0=BD=D1=8B=D1=85=20android=20=D0=BF=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B2=D0=BA=D0=B0=D1=85=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B8=20android=20=D0=BD=D0=B8=D0=B6=D0=B5=2016?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/contacts.dart | 27 +++- .../contacts/device_contacts_service.dart | 49 +++++-- lib/core/media/video_note_frame.dart | 11 ++ lib/core/storage/app_database.dart | 23 +++- .../screens/chats/chat_list_screen.dart | 1 + .../attachment/bubbles/video_note_bubble.dart | 33 +++-- lib/frontend/widgets/floating_video_note.dart | 7 +- test/contacts_deleted_accounts_test.dart | 129 ++++++++++++++++++ test/video_note_frame_test.dart | 45 ++++++ 9 files changed, 296 insertions(+), 29 deletions(-) create mode 100644 lib/core/media/video_note_frame.dart create mode 100644 test/contacts_deleted_accounts_test.dart create mode 100644 test/video_note_frame_test.dart diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 63f8a12..49ab0cd 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -21,6 +21,7 @@ class CachedContact { final String? baseRawUrl; final int updateTime; final Set options; + final int accountStatus; const CachedContact({ required this.id, @@ -33,12 +34,14 @@ class CachedContact { this.baseRawUrl, required this.updateTime, this.options = const {}, + this.accountStatus = 0, }); bool get isOfficial => options.contains('OFFICIAL'); bool get isBot => options.contains('BOT'); bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); bool get isVerified => isOfficial; + bool get isDeleted => accountStatus != 0; factory CachedContact.fromDbRow(Map row) => CachedContact( id: row['id'] as int, @@ -51,6 +54,7 @@ class CachedContact { baseRawUrl: row['base_raw_url'] as String?, updateTime: row['update_time'] as int, options: _decodeOptions(row['options']), + accountStatus: (row['account_status'] as int?) ?? 0, ); static Set _decodeOptions(dynamic raw) { @@ -183,6 +187,7 @@ class ContactsModule { 'base_raw_url': null, 'update_time': 0, 'options': null, + 'account_status': 0, }; if (row == null) return null; @@ -397,7 +402,14 @@ class ContactsModule { int accountId, ) async { final contacts = data['contacts']; - if (contacts is! List || contacts.isEmpty) return; + if (contacts is! List) { + logger.i('Контакты: сервер не прислал список (акк $accountId)'); + return; + } + if (contacts.isEmpty) { + logger.i('Контакты: сервер прислал пустой список (акк $accountId)'); + return; + } final rows = >[]; for (final raw in contacts.whereType()) { @@ -492,8 +504,14 @@ class ContactsModule { return photos; } - static Future> getContacts(int accountId) async { - final rows = await AppDatabase.loadContacts(accountId); + static Future> getContacts( + int accountId, { + bool includeDeleted = false, + }) async { + final rows = await AppDatabase.loadContacts( + accountId, + includeDeleted: includeDeleted, + ); return rows.map(CachedContact.fromDbRow).toList(); } @@ -571,7 +589,7 @@ class ContactsModule { /// Прогревает in-memory ContactCache из локальных контактов. /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. static Future primeCacheFromDb(int accountId) async { - final contacts = await getContacts(accountId); + final contacts = await getContacts(accountId, includeDeleted: true); for (final c in contacts) { ContactCache.putPhone(c.id, c.phone); final fullName = (c.lastName != null && c.lastName!.isNotEmpty) @@ -632,6 +650,7 @@ class ContactsModule { 'base_raw_url': contact['baseRawUrl'] as String?, 'update_time': (contact['updateTime'] as int?) ?? 0, 'options': optionsStr, + 'account_status': (contact['accountStatus'] as int?) ?? 0, }; } } diff --git a/lib/core/contacts/device_contacts_service.dart b/lib/core/contacts/device_contacts_service.dart index d5fe4cc..643136a 100644 --- a/lib/core/contacts/device_contacts_service.dart +++ b/lib/core/contacts/device_contacts_service.dart @@ -1,14 +1,15 @@ import 'dart:io'; import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_phonebook_names.dart'; +import '../utils/logger.dart'; class DeviceContactsService { DeviceContactsService._(); - static const _grantedKey = 'phonebook_granted'; static const _deniedKey = 'phonebook_denied'; static final Map _byLast10 = {}; @@ -16,6 +17,10 @@ class DeviceContactsService { static bool get _supported => Platform.isAndroid || Platform.isIOS; + static bool get isLoaded => _loaded; + + static int get knownNumbers => _byLast10.length; + static String? _last10(String raw) { final digits = raw.replaceAll(RegExp(r'[^\d]'), ''); if (digits.length < 10) return null; @@ -32,28 +37,43 @@ class DeviceContactsService { return name.trim(); } + static Future hasPermission() async { + if (!_supported) return false; + try { + return await Permission.contacts.isGranted; + } catch (e) { + logger.w('Телефонная книга: не удалось прочитать статус разрешения: $e'); + return false; + } + } + static Future loadFromStartup() async { if (_loaded || !_supported) return; if (!AppPhonebookNames.current.value) return; - final prefs = await SharedPreferences.getInstance(); - if (prefs.getBool(_grantedKey) != true) return; - final granted = await FlutterContacts.requestPermission(readonly: true); - if (!granted) return; + if (!await hasPermission()) return; + await _forgetDenial(); await _readBook(); } static Future ensureLoadedInteractive({bool force = false}) async { - if (_loaded || !_supported) return false; + if (!_supported) return false; if (!AppPhonebookNames.current.value) return false; + if (_loaded && !force) return false; + + if (await hasPermission()) { + await _forgetDenial(); + return _readBook(); + } + final prefs = await SharedPreferences.getInstance(); if (!force && prefs.getBool(_deniedKey) == true) return false; + final granted = await FlutterContacts.requestPermission(readonly: true); if (!granted) { await prefs.setBool(_deniedKey, true); return false; } await prefs.remove(_deniedKey); - await prefs.setBool(_grantedKey, true); return _readBook(); } @@ -63,8 +83,14 @@ class DeviceContactsService { return ensureLoadedInteractive(force: true); } + static Future _forgetDenial() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_deniedKey) == true) await prefs.remove(_deniedKey); + } + static Future _readBook() async { try { + FlutterContacts.config.includeNonVisibleOnAndroid = true; final contacts = await FlutterContacts.getContacts(withProperties: true); _byLast10.clear(); for (final contact in contacts) { @@ -78,8 +104,13 @@ class DeviceContactsService { } } _loaded = true; - return _byLast10.isNotEmpty; - } catch (_) { + logger.i( + 'Телефонная книга: прочитано ${contacts.length} записей, ' + '${_byLast10.length} номеров', + ); + return true; + } catch (e) { + logger.w('Телефонная книга: не удалось прочитать: $e'); return false; } } diff --git a/lib/core/media/video_note_frame.dart b/lib/core/media/video_note_frame.dart new file mode 100644 index 0000000..1eb806f --- /dev/null +++ b/lib/core/media/video_note_frame.dart @@ -0,0 +1,11 @@ +import 'dart:ui'; + +Size videoNoteFrameSize(Size frame, double fallback) { + final width = frame.width.isFinite && frame.width > 0 + ? frame.width + : fallback; + final height = frame.height.isFinite && frame.height > 0 + ? frame.height + : fallback; + return Size(width, height); +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 658fa2d..125f667 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -221,7 +221,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 22, + version: 23, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -353,6 +353,14 @@ class AppDatabase { await db.execute(_webAppStorageSchema); await db.execute(_webAppBiometrySchema); } + if (oldVersion < 23) { + await _addColumnIfMissing( + db, + 'contacts', + 'account_status', + 'INTEGER NOT NULL DEFAULT 0', + ); + } }, ); } @@ -465,7 +473,8 @@ class AppDatabase { base_url TEXT, base_raw_url TEXT, update_time INTEGER NOT NULL DEFAULT 0, - options TEXT + options TEXT, + account_status INTEGER NOT NULL DEFAULT 0 ) '''; @@ -798,6 +807,7 @@ class AppDatabase { static Future close() async { await _db?.close(); _db = null; + _initCompleter = null; } // Chats cache @@ -1045,11 +1055,16 @@ class AppDatabase { await batch.commit(noResult: true); } - static Future>> loadContacts(int accountId) async { + static Future>> loadContacts( + int accountId, { + bool includeDeleted = false, + }) async { final db = await _instance; return db.query( 'contacts', - where: 'account_id = ?', + where: includeDeleted + ? 'account_id = ?' + : 'account_id = ? AND account_status = 0', whereArgs: [accountId], ); } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index e4cbb20..aa47a3e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -863,6 +863,7 @@ class _ChatListScreenState extends State final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); final contactIds = (await ContactsModule.getContacts( p.id, + includeDeleted: true, )).map((c) => c.id).toSet(); const allChatsFolder = ChatFolder( diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index 737a5a4..2de3c24 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -11,6 +11,7 @@ import 'package:video_player/video_player.dart'; import 'package:komet/main.dart'; import '../../../../core/media/media_playback.dart'; +import '../../../../core/media/video_note_frame.dart'; import '../../../../core/media/video_note_preloader.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; @@ -65,6 +66,7 @@ class _VideoNoteBubbleState extends State late final AnimationController _expand; final ValueNotifier _ringProgress = ValueNotifier(0); Uint8List? _preview; + Size _frameSize = Size.zero; VideoPlayerController? _controller; VideoPlayerController? _local; Future? _initializing; @@ -195,8 +197,11 @@ class _VideoNoteBubbleState extends State ? (value.position.inMilliseconds / total).clamp(0.0, 1.0) : 0.0; } - if (value.isPlaying != _playing) { - setState(() => _playing = value.isPlaying); + if (value.size != _frameSize || value.isPlaying != _playing) { + setState(() { + _frameSize = value.size; + _playing = value.isPlaying; + }); } } @@ -239,7 +244,7 @@ class _VideoNoteBubbleState extends State _controller = live; live.addListener(_onTick); _PreviewPool.pin(this); - if (mounted) setState(() {}); + if (mounted) setState(() => _frameSize = live.value.size); return live; } final running = _initializing; @@ -272,7 +277,7 @@ class _VideoNoteBubbleState extends State await controller.seekTo(Duration.zero); controller.addListener(_onTick); _PreviewPool.register(this); - if (mounted) setState(() {}); + if (mounted) setState(() => _frameSize = controller.value.size); return controller; } @@ -283,7 +288,7 @@ class _VideoNoteBubbleState extends State _controller = null; controller.removeListener(_onTick); MediaPlayback.instance.releaseVideoNote(controller); - if (mounted) setState(() {}); + if (mounted) setState(() => _frameSize = Size.zero); } Future _toggle() async { @@ -471,9 +476,14 @@ class _VideoNoteBubbleState extends State ? _videoSurface( _controller!, const ValueKey('note-video'), + size, ) : local != null && local.value.isInitialized - ? _videoSurface(local, const ValueKey('note-local')) + ? _videoSurface( + local, + const ValueKey('note-local'), + size, + ) : _buildPoster(preview), ), ), @@ -559,15 +569,20 @@ class _VideoNoteBubbleState extends State ); } - Widget _videoSurface(VideoPlayerController controller, Key key) { + Widget _videoSurface( + VideoPlayerController controller, + Key key, + double fallback, + ) { + final frame = videoNoteFrameSize(controller.value.size, fallback); return SizedBox.expand( key: key, child: FittedBox( fit: BoxFit.cover, clipBehavior: Clip.hardEdge, child: SizedBox( - width: controller.value.size.width, - height: controller.value.size.height, + width: frame.width, + height: frame.height, child: VideoPlayer(controller), ), ), diff --git a/lib/frontend/widgets/floating_video_note.dart b/lib/frontend/widgets/floating_video_note.dart index af22734..4034944 100644 --- a/lib/frontend/widgets/floating_video_note.dart +++ b/lib/frontend/widgets/floating_video_note.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../core/media/media_playback.dart'; +import '../../core/media/video_note_frame.dart'; import '../../core/utils/haptics.dart'; import 'draggable_floating_layer.dart'; @@ -65,7 +66,7 @@ class _NoteCircle extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final frame = track.controller.value.size; + final frame = videoNoteFrameSize(track.controller.value.size, size); return SizedBox( width: size, height: size, @@ -81,8 +82,8 @@ class _NoteCircle extends StatelessWidget { fit: BoxFit.cover, clipBehavior: Clip.hardEdge, child: SizedBox( - width: frame.width <= 0 ? size : frame.width, - height: frame.height <= 0 ? size : frame.height, + width: frame.width, + height: frame.height, child: VideoPlayer(track.controller), ), ), diff --git a/test/contacts_deleted_accounts_test.dart b/test/contacts_deleted_accounts_test.dart new file mode 100644 index 0000000..5469371 --- /dev/null +++ b/test/contacts_deleted_accounts_test.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/core/storage/app_database.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _SyntheticPathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + final String directory; + + _SyntheticPathProvider(this.directory); + + @override + Future getApplicationSupportPath() async => directory; +} + +Map _serverContact({ + required int id, + required String firstName, + required int phone, + int accountStatus = 0, +}) { + return { + 'id': id, + 'phone': phone, + 'updateTime': 1, + 'accountStatus': accountStatus, + 'names': [ + {'type': 'ONEME', 'firstName': firstName, 'lastName': 'Synthetic'}, + ], + }; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const accountId = 1; + + setUp(() async { + final directory = Directory.systemTemp.createTempSync( + 'synthetic_contacts_deleted_test', + ); + PathProviderPlatform.instance = _SyntheticPathProvider(directory.path); + addTearDown(() async { + await AppDatabase.close(); + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + await AppDatabase.init(); + await AppDatabase.saveProfile( + ProfileData( + id: accountId, + firstName: 'Synthetic owner', + phone: 100000, + country: 'ZZ', + accountStatus: 0, + updateTime: 1, + ), + ); + await ContactsModule.syncFromLoginPayload({ + 'contacts': [ + _serverContact(id: 11, firstName: 'Alive', phone: 700000011), + _serverContact( + id: 12, + firstName: 'Gone', + phone: 700000012, + accountStatus: 2, + ), + ], + }, accountId); + }); + + test('deleted accounts are hidden from the contact list', () async { + final visible = await ContactsModule.getContacts(accountId); + + expect(visible.map((c) => c.id), [11]); + expect(visible.single.isDeleted, isFalse); + }); + + test('deleted accounts stay available when explicitly requested', () async { + final all = await ContactsModule.getContacts( + accountId, + includeDeleted: true, + ); + + expect(all.map((c) => c.id).toSet(), {11, 12}); + expect(all.firstWhere((c) => c.id == 12).isDeleted, isTrue); + }); + + test( + 'a re-synced deleted account does not come back into the list', + () async { + await ContactsModule.syncFromLoginPayload({ + 'contacts': [ + _serverContact( + id: 12, + firstName: 'Gone', + phone: 700000012, + accountStatus: 2, + ), + ], + }, accountId); + + final visible = await ContactsModule.getContacts(accountId); + + expect(visible.map((c) => c.id), [11]); + }, + ); + + test('a contact without accountStatus is treated as alive', () async { + await ContactsModule.syncFromLoginPayload({ + 'contacts': [ + { + 'id': 13, + 'phone': 700000013, + 'updateTime': 1, + 'names': [ + {'type': 'ONEME', 'firstName': 'Legacy', 'lastName': 'Synthetic'}, + ], + }, + ], + }, accountId); + + final visible = await ContactsModule.getContacts(accountId); + + expect(visible.map((c) => c.id).toSet(), {11, 13}); + }); +} diff --git a/test/video_note_frame_test.dart b/test/video_note_frame_test.dart new file mode 100644 index 0000000..419343e --- /dev/null +++ b/test/video_note_frame_test.dart @@ -0,0 +1,45 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/media/video_note_frame.dart'; + +void main() { + const fallback = 220.0; + + test('a reported frame is used as is', () { + expect( + videoNoteFrameSize(const Size(480, 480), fallback), + const Size(480, 480), + ); + expect( + videoNoteFrameSize(const Size(640, 360), fallback), + const Size(640, 360), + ); + }); + + test('an unreported frame falls back to a square of the circle size', () { + expect(videoNoteFrameSize(Size.zero, fallback), const Size(220, 220)); + }); + + test('a half-reported frame keeps the dimension it does have', () { + expect( + videoNoteFrameSize(const Size(480, 0), fallback), + const Size(480, 220), + ); + expect( + videoNoteFrameSize(const Size(0, 480), fallback), + const Size(220, 480), + ); + }); + + test('negative and non-finite dimensions fall back', () { + expect( + videoNoteFrameSize(const Size(-1, -1), fallback), + const Size(220, 220), + ); + expect( + videoNoteFrameSize(const Size(double.nan, double.infinity), fallback), + const Size(220, 220), + ); + }); +}