Files
Qlyra/lib/backend/modules/contacts.dart
T
sevenhill f74057ce9b
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
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
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

657 lines
18 KiB
Dart

import 'package:flutter/foundation.dart';
import '../../core/cache/info_cache.dart';
import '../../core/config/debug_test.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../models/contact_info.dart';
import '../api.dart';
import 'messages.dart';
class CachedContact {
final int id;
final int accountId;
final String firstName;
final String? lastName;
final int phone;
final int? photoId;
final String? baseUrl;
final String? baseRawUrl;
final int updateTime;
final Set<String> options;
final int accountStatus;
const CachedContact({
required this.id,
required this.accountId,
required this.firstName,
this.lastName,
required this.phone,
this.photoId,
this.baseUrl,
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<String, dynamic> row) => CachedContact(
id: row['id'] as int,
accountId: row['account_id'] as int,
firstName: row['first_name'] as String,
lastName: row['last_name'] as String?,
phone: row['phone'] as int,
photoId: row['photo_id'] as int?,
baseUrl: row['base_url'] as String?,
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<String> _decodeOptions(dynamic raw) {
if (raw is! String || raw.isEmpty) return const {};
return raw.split(',').where((s) => s.isNotEmpty).toSet();
}
}
class PhoneLookupResult {
final int id;
final String? name;
final String? avatarUrl;
final int phone;
const PhoneLookupResult({
required this.id,
this.name,
this.avatarUrl,
this.phone = 0,
});
}
class ContactPhotos {
final List<String> urls;
final int total;
const ContactPhotos({required this.urls, required this.total});
static const empty = ContactPhotos(urls: [], total: 0);
}
enum AddContactStatus { added, notFound, error }
class AddContactResult {
final AddContactStatus status;
final CachedContact? contact;
const AddContactResult(this.status, {this.contact});
}
class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static Future<PhoneLookupResult?> findByPhone(
Api api,
String phone, {
bool silent = false,
}) async {
final normalized = _normalizePhone(phone);
if (normalized == null) return null;
final Packet packet;
try {
packet = await api.sendRequest(Opcode.contactInfoByPhone, {
'phone': normalized,
}, silent: silent);
} on PacketError {
return null;
}
if (packet.isError) return null;
final contact = (packet.payload as Map?)?['contact'];
if (contact is! Map) return null;
final id = contact['id'];
if (id is! int) return null;
primeContactCache(contact);
final payloadPhone = contact['phone'];
final resolvedPhone = payloadPhone is int && payloadPhone > 0
? payloadPhone
: int.tryParse(normalized.substring(1)) ?? 0;
ContactCache.putPhone(id, resolvedPhone);
String? name;
final names = contact['names'];
if (names is List) {
final n = names.firstWhere((e) => e is Map, orElse: () => null);
if (n is Map) {
final first =
(n['firstName'] as String?) ?? (n['name'] as String?) ?? '';
final last = (n['lastName'] as String?) ?? '';
final full = '$first $last'.trim();
if (full.isNotEmpty) name = full;
}
}
return PhoneLookupResult(
id: id,
name: name,
avatarUrl: contact['baseUrl'] as String?,
phone: resolvedPhone,
);
}
static String? _normalizePhone(String raw) {
final digits = raw.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 5) return null;
return '+$digits';
}
static Future<CachedContact?> addContact(
Api api,
int id,
String firstName, {
int phone = 0,
}) async {
final resp = await api.sendRequest(Opcode.contactUpdate, {
'action': 'ADD',
'contactId': id,
if (firstName.isNotEmpty) 'firstName': firstName,
});
final profile = await AppDatabase.loadActiveProfile();
if (profile == null) return null;
final data = resp.payload;
final contact = (data is Map && data['contact'] is Map)
? (data['contact'] as Map).cast<dynamic, dynamic>()
: null;
final row = contact != null
? _parseContact(contact, profile.id)
: {
'id': id,
'account_id': profile.id,
'first_name': firstName,
'last_name': null,
'phone': 0,
'photo_id': null,
'base_url': null,
'base_raw_url': null,
'update_time': 0,
'options': null,
'account_status': 0,
};
if (row == null) return null;
if (phone > 0 && ((row['phone'] as int?) ?? 0) == 0) {
row['phone'] = phone;
}
await AppDatabase.saveContacts([row]);
if (contact != null) primeContactCache(contact);
revision.value++;
return CachedContact.fromDbRow(row);
}
static Future<AddContactResult> addContactByPhone(
Api api, {
required String phone,
required String firstName,
String lastName = '',
}) async {
final normalized = _normalizePhone(phone);
if (normalized == null) {
return const AddContactResult(AddContactStatus.error);
}
final Packet resp;
try {
resp = await api.sendRequest(Opcode.contactAddByPhone, {
'phone': normalized,
'firstName': firstName,
'lastName': lastName,
}, silent: true);
} on PacketError catch (e) {
final key = e.errorKey ?? '';
final notFound =
key == 'user.not.found' ||
e.message.toLowerCase().contains('not found');
return AddContactResult(
notFound ? AddContactStatus.notFound : AddContactStatus.error,
);
} catch (_) {
return const AddContactResult(AddContactStatus.error);
}
final data = resp.payload;
final contact = (data is Map && data['contact'] is Map)
? (data['contact'] as Map).cast<dynamic, dynamic>()
: null;
if (contact == null) {
return const AddContactResult(AddContactStatus.error);
}
final profile = await AppDatabase.loadActiveProfile();
if (profile == null) {
return const AddContactResult(AddContactStatus.error);
}
final row = _parseContact(contact, profile.id);
if (row == null) {
return const AddContactResult(AddContactStatus.error);
}
await AppDatabase.saveContacts([row]);
primeContactCache(contact);
revision.value++;
return AddContactResult(
AddContactStatus.added,
contact: CachedContact.fromDbRow(row),
);
}
static Future<CachedContact?> updateContact(
Api api, {
required int contactId,
required String firstName,
required String lastName,
}) async {
final Packet resp;
try {
resp = await api.sendRequest(Opcode.contactUpdate, {
'contactId': contactId,
'action': 'UPDATE',
'firstName': firstName,
'lastName': lastName,
});
} catch (_) {
return null;
}
final profile = await AppDatabase.loadActiveProfile();
if (profile == null) return null;
final data = resp.payload;
final contact = (data is Map && data['contact'] is Map)
? (data['contact'] as Map).cast<dynamic, dynamic>()
: null;
if (contact == null) return null;
final row = _parseContact(contact, profile.id);
if (row == null) return null;
await AppDatabase.saveContacts([row]);
primeContactCache(contact);
ContactInfoFetch.putContact(contactId, contact);
revision.value++;
return CachedContact.fromDbRow(row);
}
static Future<bool> removeContact(Api api, int contactId) async {
try {
await api.sendRequest(Opcode.contactUpdate, {
'contactId': contactId,
'action': 'REMOVE',
});
} catch (_) {
return false;
}
final profile = await AppDatabase.loadActiveProfile();
if (profile != null) {
await AppDatabase.deleteContact(profile.id, contactId);
}
ContactCache.remove(contactId);
ContactInfo? info = ContactInfoFetch.peek(contactId);
if (info == null) {
ContactInfoFetch.invalidate(contactId);
info = await ContactInfoFetch.get(contactId, forceRefresh: true);
}
final rawNames = info?.raw['names'];
if (info != null && rawNames is List) {
final stripped = rawNames
.where((n) => !(n is Map && n['type'] == 'CUSTOM'))
.toList();
final newRaw = Map<String, dynamic>.from(info.raw)..['names'] = stripped;
ContactInfoFetch.putContact(contactId, newRaw);
primeContactCache(newRaw);
} else {
ContactInfoFetch.invalidate(contactId);
}
revision.value++;
return true;
}
static final Set<int> _blockedIds = <int>{};
static bool _blockedLoaded = false;
static void clearBlockedCache() {
_blockedIds.clear();
_blockedLoaded = false;
}
static const int _blockedPageSize = 100;
static const int _blockedMaxPages = 20;
static Future<bool> isBlocked(Api api, int contactId) async {
if (!_blockedLoaded) await _loadBlockedIds(api);
return _blockedIds.contains(contactId);
}
static Future<void> _loadBlockedIds(Api api) async {
final ids = <int>{};
try {
for (var page = 0; page < _blockedMaxPages; page++) {
final map = await api.sendRequestMap(Opcode.contactList, {
'status': 'BLOCKED',
'count': _blockedPageSize,
'from': page * _blockedPageSize,
});
final contacts = map?['contacts'];
if (contacts is! List) return;
ids.addAll(
contacts.whereType<Map>().map((c) => c['id']).whereType<int>(),
);
if (contacts.length < _blockedPageSize) break;
}
} catch (e) {
logger.w('Не удалось получить список заблокированных: $e');
return;
}
_blockedIds
..clear()
..addAll(ids);
_blockedLoaded = true;
}
static Future<bool> setBlocked(Api api, int contactId, bool blocked) async {
try {
final packet = await api.sendRequest(Opcode.contactUpdate, {
'contactId': contactId,
'action': blocked ? 'BLOCK' : 'UNBLOCK',
});
if (packet.isError) return false;
} catch (e) {
logger.w('setBlocked $contactId: $e');
return false;
}
if (blocked) {
_blockedIds.add(contactId);
} else {
_blockedIds.remove(contactId);
}
ContactInfoFetch.invalidate(contactId);
revision.value++;
return true;
}
static Future<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
int accountId,
) async {
final contacts = data['contacts'];
if (contacts is! List) {
logger.i('Контакты: сервер не прислал список (акк $accountId)');
return;
}
if (contacts.isEmpty) {
logger.i('Контакты: сервер прислал пустой список (акк $accountId)');
return;
}
final rows = <Map<String, dynamic>>[];
for (final raw in contacts.whereType<Map>()) {
final contact = raw.cast<dynamic, dynamic>();
final row = _parseContact(contact, accountId);
if (row != null) rows.add(row);
primeContactCache(contact);
}
if (rows.isNotEmpty) {
await AppDatabase.saveContacts(rows);
revision.value++;
}
logger.i(
'Контакты: получено ${contacts.length}, сохранено ${rows.length} (акк $accountId)',
);
}
static Future<void> syncFromServer(Api api, int accountId) async {
final map = await api.sendRequestMap(Opcode.contactsGet, {
'contactsSync': 0,
});
if (map == null) return;
await syncFromLoginPayload(map.cast<dynamic, dynamic>(), accountId);
}
static Future<ProfileData?> fetchSelfProfile(Api api, int accountId) async {
final map = await api.sendRequestMap(Opcode.contactInfo, {
'contactIds': [accountId],
});
final contacts = map?['contacts'];
if (contacts is! List) return null;
for (final raw in contacts.whereType<Map>()) {
if (raw['id'] != accountId) continue;
final contact = raw.cast<dynamic, dynamic>();
primeContactCache(contact);
return ProfileData.fromServerMap(contact);
}
return null;
}
static void primeContactCache(Map<dynamic, dynamic> contact) {
final id = contact['id'];
if (id is! int) return;
final phone = contact['phone'];
if (phone is int) ContactCache.putPhone(id, phone);
final names = contact['names'];
if (names is List && names.isNotEmpty) {
final nameRaw = _preferredNameEntry(names);
if (nameRaw != null) {
final firstName = (nameRaw['firstName'] as String?) ?? '';
final lastName = nameRaw['lastName'] as String?;
final fullName = (lastName != null && lastName.isNotEmpty)
? '$firstName $lastName'
: firstName;
if (fullName.isNotEmpty) ContactCache.put(id, fullName);
}
}
final baseUrl = contact['baseUrl'] as String?;
if (baseUrl != null && baseUrl.isNotEmpty) {
ContactCache.putAvatar(id, baseUrl);
}
}
static final Map<int, ContactPhotos> _photosHead = {};
static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId];
static Future<ContactPhotos> fetchPhotos(
Api api,
int contactId, {
int from = 0,
int count = 25,
}) async {
if (contactId <= 0) return ContactPhotos.empty;
final map = await api.sendRequestMap(Opcode.contactPhotos, {
'contactId': contactId,
'from': from,
'count': count,
});
if (map == null) return ContactPhotos.empty;
final rawUrls = map['urls'];
final urls = rawUrls is List
? rawUrls.whereType<String>().toList()
: <String>[];
final total = map['total'] is int ? map['total'] as int : urls.length;
final photos = ContactPhotos(urls: urls, total: total);
if (from == 0) _photosHead[contactId] = photos;
return photos;
}
static Future<List<CachedContact>> getContacts(
int accountId, {
bool includeDeleted = false,
}) async {
final rows = await AppDatabase.loadContacts(
accountId,
includeDeleted: includeDeleted,
);
return rows.map(CachedContact.fromDbRow).toList();
}
static Future<CachedContact?> getContact(int accountId, int id) async {
final row = await AppDatabase.loadContact(accountId, id);
return row == null ? null : CachedContact.fromDbRow(row);
}
static const List<String> _debugFirstNames = [
'Алиса',
'Борис',
'Вера',
'Глеб',
'Дарья',
'Егор',
'Жанна',
'Захар',
'Ирина',
'Кирилл',
'Лия',
'Максим',
'Нина',
'Олег',
'Полина',
'Роман',
'София',
'Тимур',
'Ульяна',
'Фёдор',
'Ханна',
'Цветана',
'Чеслав',
'Шура',
];
static const List<String> _debugLastNames = [
'Иванов',
'Петров',
'Сидоров',
'Кузнецов',
'Смирнов',
'Попов',
'Волков',
'Соколов',
'Морозов',
'Новиков',
'Фёдоров',
'Козлов',
];
static List<CachedContact> debugContacts() {
final count = DebugTest.contactCount;
final out = <CachedContact>[];
for (var i = 0; i < count; i++) {
final first = _debugFirstNames[i % _debugFirstNames.length];
final last =
_debugLastNames[(i ~/ _debugFirstNames.length) %
_debugLastNames.length];
out.add(
CachedContact(
id: 900000000 + i,
accountId: DebugTest.debugAccountId,
firstName: '$first ${i + 1}',
lastName: last,
phone: 79000000000 + i,
baseUrl: 'https://i.pravatar.cc/150?u=qlyra_debug_$i',
updateTime: 1,
options: i % 6 == 0 ? const {'OFFICIAL'} : const {},
),
);
}
return out;
}
/// Прогревает in-memory ContactCache из локальных контактов.
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
static Future<void> primeCacheFromDb(int accountId) async {
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)
? '${c.firstName} ${c.lastName}'
: c.firstName;
if (fullName.isNotEmpty) ContactCache.put(c.id, fullName);
if (c.baseUrl != null && c.baseUrl!.isNotEmpty) {
ContactCache.putAvatar(c.id, c.baseUrl);
}
}
}
static Map? _preferredNameEntry(List names) {
Map? oneme;
Map? any;
for (final n in names) {
if (n is! Map) continue;
any ??= n;
final type = n['type'];
if (type == 'CUSTOM') return n;
if (type == 'ONEME') oneme ??= n;
}
return oneme ?? any;
}
static Map<String, dynamic>? _parseContact(
Map<dynamic, dynamic> contact,
int accountId,
) {
final id = contact['id'];
if (id is! int) return null;
String firstName = '';
String? lastName;
final names = contact['names'];
if (names is List && names.isNotEmpty) {
final nameRaw = _preferredNameEntry(names);
if (nameRaw == null) return null;
firstName = (nameRaw['firstName'] as String?) ?? '';
lastName = nameRaw['lastName'] as String?;
}
final optionsRaw = contact['options'];
String? optionsStr;
if (optionsRaw is List) {
optionsStr = optionsRaw.whereType<String>().join(',');
}
return {
'id': id,
'account_id': accountId,
'first_name': firstName,
'last_name': lastName,
'phone': (contact['phone'] as int?) ?? 0,
'photo_id': contact['photoId'] as int?,
'base_url': contact['baseUrl'] as String?,
'base_raw_url': contact['baseRawUrl'] as String?,
'update_time': (contact['updateTime'] as int?) ?? 0,
'options': optionsStr,
'account_status': (contact['accountStatus'] as int?) ?? 0,
};
}
}