From 694298167405e7ab095c3c2af2c3d21d2ab081bd Mon Sep 17 00:00:00 2001 From: sevenhill Date: Thu, 8 Oct 2026 05:28:52 +0300 Subject: [PATCH] Fix contact names before initial chat rendering (1.0.5) --- lib/backend/modules/messages.dart | 20 ++- lib/core/contacts/startup_contact_names.dart | 27 +++ lib/main.dart | 24 ++- pubspec.yaml | 2 +- test/startup_contact_names_test.dart | 175 +++++++++++++++++++ 5 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 lib/core/contacts/startup_contact_names.dart create mode 100644 test/startup_contact_names_test.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 264b9ed..b3a7ea6 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -42,9 +42,11 @@ class ContactCache { final name = value['n']; final avatar = value['a']; final opts = value['o']; + final phone = value['p']; if (name is String) _nameCache[id] = name; if (avatar is String) _avatarCache[id] = avatar; if (opts is List) _optionsCache[id] = opts.whereType().toSet(); + if (phone is int && phone > 0) _phoneCache[id] = phone; }); } catch (_) {} } @@ -67,7 +69,9 @@ class ContactCache { } static void putPhone(int id, int phone) { - if (phone > 0) _phoneCache[id] = phone; + if (phone <= 0 || _phoneCache[id] == phone) return; + _phoneCache[id] = phone; + _scheduleSave(); } static String? get(int id) { @@ -102,6 +106,17 @@ class ContactCache { unawaited(_wipePersisted()); } + @visibleForTesting + static void resetMemoryForTesting() { + _nameCache.clear(); + _avatarCache.clear(); + _optionsCache.clear(); + _phoneCache.clear(); + _saveTimer?.cancel(); + _saveTimer = null; + _loaded = false; + } + static void _scheduleSave() { _saveTimer?.cancel(); _saveTimer = Timer(const Duration(seconds: 3), () => unawaited(_save())); @@ -112,6 +127,7 @@ class ContactCache { ..._nameCache.keys, ..._avatarCache.keys, ..._optionsCache.keys, + ..._phoneCache.keys, }; final map = {}; for (final id in ids) { @@ -119,9 +135,11 @@ class ContactCache { final name = _nameCache[id]; final avatar = _avatarCache[id]; final opts = _optionsCache[id]; + final phone = _phoneCache[id]; if (name != null) entry['n'] = name; if (avatar != null) entry['a'] = avatar; if (opts != null && opts.isNotEmpty) entry['o'] = opts.toList(); + if (phone != null) entry['p'] = phone; if (entry.isNotEmpty) map['$id'] = entry; } final prefs = await SharedPreferences.getInstance(); diff --git a/lib/core/contacts/startup_contact_names.dart b/lib/core/contacts/startup_contact_names.dart new file mode 100644 index 0000000..d7edc2d --- /dev/null +++ b/lib/core/contacts/startup_contact_names.dart @@ -0,0 +1,27 @@ +/// Prepare local names before the first chat list is rendered. +/// Neither operation requires a server connection or a permission prompt. +Future prepareStartupContactNames({ + required Future accountReady, + required Future settingsReady, + required Future cacheReady, + required Future Function(int accountId) primeContacts, + required Future Function() loadPhonebook, + required void Function(String source, Object error, StackTrace stack) onError, +}) async { + await Future.wait([settingsReady, cacheReady]); + final accountId = await accountReady; + + Future attempt(String source, Future Function() load) async { + try { + await load(); + } catch (error, stack) { + onError(source, error, stack); + } + } + + await Future.wait([ + if (accountId != null) + attempt('cached contacts', () => primeContacts(accountId)), + attempt('phonebook', loadPhonebook), + ]); +} diff --git a/lib/main.dart b/lib/main.dart index f7fe49a..0539ec9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -53,6 +53,7 @@ import 'core/config/app_commands.dart'; import 'core/config/app_phonebook_names.dart'; import 'core/contacts/phonebook_contact_sync.dart'; import 'core/contacts/device_contacts_service.dart'; +import 'core/contacts/startup_contact_names.dart'; import 'core/config/app_link_preview.dart'; import 'core/config/app_media_cache.dart'; import 'core/config/app_video_note_quality.dart'; @@ -266,6 +267,16 @@ void main(List args) async { final storiesFuture = AppStories.load(); final commandsFuture = AppCommands.load(); final phonebookNamesFuture = AppPhonebookNames.load(); + final contactCacheFuture = ContactCache.load(); + final contactNamesReady = prepareStartupContactNames( + accountReady: accountReady, + settingsReady: phonebookNamesFuture.then((_) {}), + cacheReady: contactCacheFuture, + primeContacts: ContactsModule.primeCacheFromDb, + loadPhonebook: DeviceContactsService.loadFromStartup, + onError: (source, error, stack) => + logger.e('Startup $source failed: $error', stackTrace: stack), + ); final linkPreviewFuture = AppLinkPreview.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); final videoNoteResolutionFuture = AppVideoNoteResolution.load(); @@ -292,7 +303,7 @@ void main(List args) async { ChatEncryptionStore.instance.load(), QlyraSettings.load(), AppLock.instance.load(), - ContactCache.load(), + contactCacheFuture, ]); E2eeService.instance.attach(messagesModule); if (QlyraSettings.ghostMode.value) SelfPresence.markOffline(); @@ -352,6 +363,7 @@ void main(List args) async { await transportReady; await trafficCaptureFuture; await debugLogFuture; + await contactNamesReady; runApp( QlyraApp( initialHasAccount: DebugTest.enabled || activeAccountId != null, @@ -365,11 +377,11 @@ void main(List args) async { ), ); WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_loadOptionalStartupServices(activeAccountId)); + unawaited(_loadOptionalStartupServices()); }); } -Future _loadOptionalStartupServices(int? accountId) async { +Future _loadOptionalStartupServices() async { Future attempt(String name, Future Function() load) async { try { await load(); @@ -383,12 +395,6 @@ Future _loadOptionalStartupServices(int? accountId) async { attempt('push', () => FkmController.instance.init(api)), attempt('stories', storiesModule.loadCache), attempt('links', DeepLinkService.instance.init), - attempt('contacts', () async { - if (accountId != null) await ContactsModule.primeCacheFromDb(accountId); - await DeviceContactsService.loadFromStartup(); - // Refresh names after the phone book arrives behind the chat list. - ContactsModule.revision.value++; - }), ]); } diff --git a/pubspec.yaml b/pubspec.yaml index af12d1c..f99d62c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.4+5 +version: 1.0.5+6 environment: sdk: ^3.10.4 diff --git a/test/startup_contact_names_test.dart b/test/startup_contact_names_test.dart new file mode 100644 index 0000000..37838d0 --- /dev/null +++ b/test/startup_contact_names_test.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ikar/backend/modules/contacts.dart'; +import 'package:ikar/backend/modules/messages.dart'; +import 'package:ikar/core/contacts/startup_contact_names.dart'; +import 'package:ikar/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'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Paths extends PathProviderPlatform with MockPlatformInterfaceMixin { + _Paths(this.directory); + final String directory; + @override + Future getApplicationSupportPath() async => directory; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + SharedPreferences.setMockInitialValues({}); + ContactCache.resetMemoryForTesting(); + }); + tearDown(ContactCache.resetMemoryForTesting); + + testWidgets('first frame waits for local contact and phonebook names', ( + tester, + ) async { + await tester.runAsync(() async { + final directory = Directory.systemTemp.createTempSync( + 'ikar_startup_names', + ); + final previousPaths = PathProviderPlatform.instance; + PathProviderPlatform.instance = _Paths(directory.path); + try { + await AppDatabase.init(); + await AppDatabase.saveProfile(ProfileData.stub(1)); + await ContactsModule.syncFromLoginPayload({ + 'contacts': [ + { + 'id': 7, + 'phone': 79000000007, + 'names': [ + {'type': 'CUSTOM', 'firstName': 'Имя из контактов'}, + ], + }, + ], + }, 1); + ContactCache.put(7, 'Имя из профиля'); + final phonebookRead = Completer(); + final startedPhonebook = Completer(); + final ready = prepareStartupContactNames( + accountReady: Future.value(1), + settingsReady: Future.value(), + cacheReady: ContactCache.load(), + primeContacts: ContactsModule.primeCacheFromDb, + loadPhonebook: () { + startedPhonebook.complete(); + return phonebookRead.future; + }, + onError: (_, error, _) => fail('$error'), + ); + var firstFrameReady = false; + final startup = ready.then((_) => firstFrameReady = true); + await startedPhonebook.future; + await Future.delayed(Duration.zero); + expect(firstFrameReady, isFalse); + phonebookRead.complete(); + await startup; + } finally { + await AppDatabase.close(); + PathProviderPlatform.instance = previousPaths; + directory.deleteSync(recursive: true); + } + }); + await tester.pumpWidget(MaterialApp(home: Text(ContactCache.get(7)!))); + expect(find.text('Имя из контактов'), findsOneWidget); + expect(find.text('Имя из профиля'), findsNothing); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Имя из контактов'), findsOneWidget); + }); + + test( + 'restored cache cannot overwrite database names during startup', + () async { + final cache = Completer(); + final settings = Completer(); + final calls = []; + final ready = prepareStartupContactNames( + accountReady: Future.value(42), + settingsReady: settings.future, + cacheReady: cache.future, + primeContacts: (id) async => calls.add('contacts:$id'), + loadPhonebook: () async => calls.add('phonebook'), + onError: (_, error, _) => fail('$error'), + ); + await Future.delayed(Duration.zero); + expect(calls, isEmpty); + settings.complete(); + await Future.delayed(Duration.zero); + expect(calls, isEmpty); + cache.complete(); + await ready; + expect(calls, ['contacts:42', 'phonebook']); + }, + ); + + test('without an account there is no contact database read', () async { + final calls = []; + await prepareStartupContactNames( + accountReady: Future.value(null), + settingsReady: Future.value(), + cacheReady: Future.value(), + primeContacts: (_) async => calls.add('contacts'), + loadPhonebook: () async => calls.add('phonebook'), + onError: (_, error, _) => fail('$error'), + ); + expect(calls, ['phonebook']); + }); + + test( + 'unavailable phonebook leaves cached names and does not block startup', + () async { + final errors = []; + await prepareStartupContactNames( + accountReady: Future.value(1), + settingsReady: Future.value(), + cacheReady: Future.value(), + primeContacts: (_) async => ContactCache.put(7, 'Локальное имя'), + loadPhonebook: () async => throw StateError('Permission unavailable'), + onError: (source, _, _) => errors.add(source), + ); + expect(ContactCache.get(7), 'Локальное имя'); + expect(errors, ['phonebook']); + }, + ); + + testWidgets('phone numbers survive a cache save after a cold load', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'contact_cache_v1': jsonEncode({ + '7': {'n': 'Серверное имя', 'p': 79000000007}, + '8': {'p': 79000000008}, + '9': {'n': 'Старый формат'}, + }), + }); + await ContactCache.load(); + ContactCache.put(7, 'Новое серверное имя'); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + final prefs = await SharedPreferences.getInstance(); + final stored = jsonDecode(prefs.getString('contact_cache_v1')!) as Map; + expect(stored['7']['p'], 79000000007); + expect(stored['8']['p'], 79000000008); + expect(stored['9']['n'], 'Старый формат'); + }); + + testWidgets('phone-only updates are saved and invalid numbers are ignored', ( + tester, + ) async { + ContactCache.putPhone(7, 79000000007); + ContactCache.putPhone(8, 0); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + final prefs = await SharedPreferences.getInstance(); + final stored = jsonDecode(prefs.getString('contact_cache_v1')!) as Map; + expect(stored['7']['p'], 79000000007); + expect(stored.containsKey('8'), isFalse); + }); +}