Fix contact names before initial chat rendering (1.0.5)
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -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<String>().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 = <String, dynamic>{};
|
||||
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();
|
||||
|
||||
@@ -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<void> prepareStartupContactNames({
|
||||
required Future<int?> accountReady,
|
||||
required Future<void> settingsReady,
|
||||
required Future<void> cacheReady,
|
||||
required Future<void> Function(int accountId) primeContacts,
|
||||
required Future<void> Function() loadPhonebook,
|
||||
required void Function(String source, Object error, StackTrace stack) onError,
|
||||
}) async {
|
||||
await Future.wait<void>([settingsReady, cacheReady]);
|
||||
final accountId = await accountReady;
|
||||
|
||||
Future<void> attempt(String source, Future<void> Function() load) async {
|
||||
try {
|
||||
await load();
|
||||
} catch (error, stack) {
|
||||
onError(source, error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait<void>([
|
||||
if (accountId != null)
|
||||
attempt('cached contacts', () => primeContacts(accountId)),
|
||||
attempt('phonebook', loadPhonebook),
|
||||
]);
|
||||
}
|
||||
+15
-9
@@ -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<String> 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<String> 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<String> args) async {
|
||||
await transportReady;
|
||||
await trafficCaptureFuture;
|
||||
await debugLogFuture;
|
||||
await contactNamesReady;
|
||||
runApp(
|
||||
QlyraApp(
|
||||
initialHasAccount: DebugTest.enabled || activeAccountId != null,
|
||||
@@ -365,11 +377,11 @@ void main(List<String> args) async {
|
||||
),
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_loadOptionalStartupServices(activeAccountId));
|
||||
unawaited(_loadOptionalStartupServices());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadOptionalStartupServices(int? accountId) async {
|
||||
Future<void> _loadOptionalStartupServices() async {
|
||||
Future<void> attempt(String name, Future<void> Function() load) async {
|
||||
try {
|
||||
await load();
|
||||
@@ -383,12 +395,6 @@ Future<void> _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++;
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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<String?> 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<void>();
|
||||
final startedPhonebook = Completer<void>();
|
||||
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<void>.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<void>();
|
||||
final settings = Completer<void>();
|
||||
final calls = <String>[];
|
||||
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<void>.delayed(Duration.zero);
|
||||
expect(calls, isEmpty);
|
||||
settings.complete();
|
||||
await Future<void>.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 = <String>[];
|
||||
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 = <String>[];
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user