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
160 lines
4.9 KiB
Dart
160 lines
4.9 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
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 _deniedKey = 'phonebook_denied';
|
|
|
|
static final Map<String, String> _byLast10 = {};
|
|
static final Map<String, String> _phonebook = {};
|
|
static Map<String, String> get phonebook => Map.unmodifiable(_phonebook);
|
|
static bool _loaded = false;
|
|
static Future<bool>? _readInFlight;
|
|
|
|
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;
|
|
return digits.substring(digits.length - 10);
|
|
}
|
|
|
|
static String? nameForPhone(int phone) {
|
|
if (!AppPhonebookNames.current.value) return null;
|
|
if (_byLast10.isEmpty) return null;
|
|
final key = _last10(phone.toString());
|
|
if (key == null) return null;
|
|
final name = _byLast10[key];
|
|
if (name == null || name.trim().isEmpty) return null;
|
|
return name.trim();
|
|
}
|
|
|
|
static Future<bool> hasPermission() async {
|
|
if (!_supported) return false;
|
|
try {
|
|
return await Permission.contacts.isGranted;
|
|
} catch (e) {
|
|
logger.w('Телефонная книга: не удалось прочитать статус разрешения: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<void> loadFromStartup() async {
|
|
if (_loaded || !_supported) return;
|
|
if (!AppPhonebookNames.current.value) return;
|
|
if (!await hasPermission()) return;
|
|
await _forgetDenial();
|
|
await _readBook();
|
|
}
|
|
|
|
static Future<bool> ensureLoadedInteractive({
|
|
bool force = false,
|
|
bool forContacts = false,
|
|
bool refresh = false,
|
|
}) async {
|
|
if (!_supported) return false;
|
|
if (!forContacts && !AppPhonebookNames.current.value) return false;
|
|
if (_loaded && !force && !refresh) 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);
|
|
return _readBook();
|
|
}
|
|
|
|
static Future<bool> reload() => ensureLoadedInteractive(force: true);
|
|
|
|
@visibleForTesting
|
|
static Future<bool> readBookForTesting() => _readBook();
|
|
|
|
@visibleForTesting
|
|
static void resetMemoryForTesting() {
|
|
_loaded = false;
|
|
_byLast10.clear();
|
|
_phonebook.clear();
|
|
_readInFlight = null;
|
|
}
|
|
|
|
static Future<void> _forgetDenial() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (prefs.getBool(_deniedKey) == true) await prefs.remove(_deniedKey);
|
|
}
|
|
|
|
static Future<bool> _readBook() => _readInFlight ??= _readBookOnce()
|
|
.whenComplete(() => _readInFlight = null);
|
|
|
|
static Future<bool> _readBookOnce() async {
|
|
try {
|
|
FlutterContacts.config.includeNonVisibleOnAndroid = true;
|
|
final contacts = await FlutterContacts.getContacts(withProperties: true);
|
|
final nextByLast10 = <String, String>{};
|
|
final nextPhonebook = <String, String>{};
|
|
for (final contact in contacts) {
|
|
final name = contact.displayName.trim();
|
|
for (final phone in contact.phones) {
|
|
final normalized = normalizePhone(phone.number);
|
|
if (normalized != null) {
|
|
nextPhonebook.putIfAbsent(
|
|
normalized,
|
|
() => name.isEmpty ? normalized : name,
|
|
);
|
|
}
|
|
final key = _last10(phone.number);
|
|
if (key != null) {
|
|
nextByLast10.putIfAbsent(key, () => name);
|
|
}
|
|
}
|
|
}
|
|
// Keep the last complete snapshot while the native reader is awaiting.
|
|
// Replacing both maps synchronously prevents a fallback to server names.
|
|
_byLast10
|
|
..clear()
|
|
..addAll(nextByLast10);
|
|
_phonebook
|
|
..clear()
|
|
..addAll(nextPhonebook);
|
|
_loaded = true;
|
|
logger.i(
|
|
'Телефонная книга: прочитано ${contacts.length} записей, '
|
|
'${_byLast10.length} номеров',
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
logger.w('Телефонная книга: не удалось прочитать: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static String? normalizePhone(String raw) {
|
|
var digits = raw.replaceAll(RegExp(r'[^\d]'), '');
|
|
if (digits.length == 11 && digits.startsWith('8')) {
|
|
digits = '7${digits.substring(1)}';
|
|
}
|
|
if (digits.length < 10 || digits.length > 15) return null;
|
|
return '+$digits';
|
|
}
|
|
}
|