Import IKAR messenger client and server

This commit is contained in:
sevenhill
2026-10-07 22:06:07 +03:00
commit 69ea5091eb
1951 changed files with 424546 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../backend/api.dart';
import '../../models/bot_info.dart';
import '../../models/chat_info.dart';
import '../../models/contact_info.dart';
import '../protocol/opcode_map.dart';
import '../storage/chat_members_store.dart';
Api? _api;
void attachInfoCacheApi(Api api) {
_api = api;
}
class _Entry<T> {
T? value;
DateTime? fetchedAt;
DateTime? failedAt;
Future<T?>? inFlight;
}
class InfoCache<T> {
final Duration ttl;
final Duration failureBackoff;
final Future<T?> Function(int id) fetcher;
final Map<int, _Entry<T>> _entries = {};
InfoCache({
required this.ttl,
required this.fetcher,
this.failureBackoff = const Duration(seconds: 10),
});
bool _isFresh(_Entry<T> e) {
if (e.fetchedAt == null) return false;
return DateTime.now().difference(e.fetchedAt!) < ttl;
}
bool _isInFailureBackoff(_Entry<T> e) {
if (e.failedAt == null) return false;
return DateTime.now().difference(e.failedAt!) < failureBackoff;
}
Future<T?> get(int id, {bool forceRefresh = false}) {
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
if (!forceRefresh && _isFresh(entry)) {
return Future.value(entry.value);
}
if (!forceRefresh && _isInFailureBackoff(entry)) {
return Future.value(null);
}
if (entry.inFlight != null) return entry.inFlight!;
final future = _runFetch(entry, id);
entry.inFlight = future;
return future;
}
Future<T?> _runFetch(_Entry<T> entry, int id) async {
try {
final result = await fetcher(id);
if (result == null) {
entry.failedAt = DateTime.now();
return null;
}
entry.value = result;
entry.fetchedAt = DateTime.now();
entry.failedAt = null;
return result;
} catch (_) {
entry.failedAt = DateTime.now();
return null;
} finally {
entry.inFlight = null;
}
}
T? peek(int id) {
final entry = _entries[id];
if (entry == null || !_isFresh(entry)) return null;
return entry.value;
}
void invalidate(int id) => _entries.remove(id);
void clear() => _entries.clear();
void putValue(int id, T value, {DateTime? at}) {
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
entry.value = value;
entry.fetchedAt = at ?? DateTime.now();
entry.failedAt = null;
}
void markFailed(int id, {DateTime? at}) {
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
entry.failedAt = at ?? DateTime.now();
}
}
class ContactInfoFetch {
static final _cache = InfoCache<ContactInfo>(
ttl: const Duration(minutes: 5),
fetcher: _fetch,
);
static Future<ContactInfo?> get(int id, {bool forceRefresh = false}) =>
_cache.get(id, forceRefresh: forceRefresh);
static ContactInfo? peek(int id) => _cache.peek(id);
static void invalidate(int id) => _cache.invalidate(id);
static void clear() => _cache.clear();
static void putContact(int id, Map<dynamic, dynamic> contact) {
_cache.putValue(
id,
ContactInfo.fromMap(Map<String, dynamic>.from(contact)),
);
}
static Future<Map<int, ContactInfo>> getMany(
List<int> ids, {
bool forceRefresh = false,
}) async {
final result = <int, ContactInfo>{};
final missing = <int>[];
for (final id in ids) {
if (!forceRefresh) {
final cached = _cache.peek(id);
if (cached != null) {
result[id] = cached;
continue;
}
}
missing.add(id);
}
if (missing.isEmpty) return result;
final api = _api;
if (api == null || api.state != SessionState.online) return result;
try {
final resp = await api.sendRequest(Opcode.contactInfo, {
'contactIds': missing,
});
final data = resp.payload;
final contacts = data is Map ? data['contacts'] : null;
if (contacts is List) {
final now = DateTime.now();
for (final c in contacts.whereType<Map>()) {
final id = c['id'];
if (id is! int) continue;
final info = ContactInfo.fromMap(Map<String, dynamic>.from(c));
_cache.putValue(id, info, at: now);
result[id] = info;
}
}
} catch (_) {}
return result;
}
static Future<ContactInfo?> _fetch(int id) async {
final api = _api;
if (api == null || api.state != SessionState.online) return null;
final resp = await api.sendRequest(Opcode.contactInfo, {
'contactIds': [id],
});
final data = resp.payload;
if (data is! Map) return null;
final contacts = data['contacts'];
if (contacts is! List || contacts.isEmpty) return null;
final first = contacts.first;
if (first is! Map) return null;
return ContactInfo.fromMap(Map<String, dynamic>.from(first));
}
}
class PresenceFetch {
static final _cache = InfoCache<Map<String, dynamic>>(
ttl: const Duration(seconds: 60),
fetcher: _fetch,
);
static Future<Map<String, dynamic>?> get(
int id, {
bool forceRefresh = false,
}) => _cache.get(id, forceRefresh: forceRefresh);
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
static final Map<int, Map<String, dynamic>> _live = {};
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static Map<String, dynamic>? live(int id) => _live[id] ?? _cache.peek(id);
static bool isOnline(int id) => (live(id)?['status'] as int?) == 1;
static void apply(int id, Map<String, dynamic> presence) {
if (id <= 0) return;
_live[id] = presence;
_cache.putValue(id, presence);
revision.value++;
}
static void invalidate(int id) => _cache.invalidate(id);
static void clear() {
_cache.clear();
_live.clear();
revision.value++;
}
static void primeAll(Map<dynamic, dynamic> presence) {
final now = DateTime.now();
presence.forEach((key, value) {
if (value is! Map) return;
final id = key is int ? key : int.tryParse(key.toString());
if (id == null) return;
final map = Map<String, dynamic>.from(value);
_cache.putValue(id, map, at: now);
_live[id] = map;
});
revision.value++;
}
static Future<Map<String, dynamic>?> _fetch(int id) async {
final results = await _fetchBatch([id]);
return results[id];
}
static Future<Map<int, Map<String, dynamic>>> getMany(
List<int> ids, {
bool forceRefresh = false,
}) async {
final result = <int, Map<String, dynamic>>{};
final missing = <int>[];
for (final id in ids) {
if (!forceRefresh) {
final cached = _cache.peek(id);
if (cached != null) {
result[id] = cached;
continue;
}
}
missing.add(id);
}
if (missing.isNotEmpty) {
final fetched = await _fetchBatch(missing);
final now = DateTime.now();
var changed = false;
for (final id in missing) {
final value = fetched[id];
if (value != null) {
_cache.putValue(id, value, at: now);
_live[id] = value;
result[id] = value;
changed = true;
} else {
_cache.markFailed(id, at: now);
}
}
if (changed) revision.value++;
}
return result;
}
static const _batchSize = 100;
static Future<void> ensureFor(Iterable<int> ids) async {
final wanted = <int>{};
for (final id in ids) {
if (id <= 0) continue;
if (_cache.peek(id) != null) continue;
wanted.add(id);
}
if (wanted.isEmpty) return;
final list = wanted.toList();
for (var i = 0; i < list.length; i += _batchSize) {
final chunk = list.sublist(
i,
i + _batchSize > list.length ? list.length : i + _batchSize,
);
await getMany(chunk);
}
}
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(
List<int> ids,
) async {
final api = _api;
if (api == null || api.state != SessionState.online || ids.isEmpty) {
return const {};
}
final resp = await api.sendRequest(Opcode.contactPresence, {
'contactIds': ids,
});
final data = resp.payload;
if (data is! Map) return const {};
final presence = data['presence'];
if (presence is! Map) return const {};
final out = <int, Map<String, dynamic>>{};
for (final id in ids) {
final entry = presence[id.toString()] ?? presence[id];
if (entry is Map) {
out[id] = Map<String, dynamic>.from(entry);
}
}
return out;
}
}
class BotInfoFetch {
static final _cache = InfoCache<BotInfo>(
ttl: const Duration(minutes: 30),
fetcher: _fetch,
);
static Future<BotInfo?> get(int botId, {bool forceRefresh = false}) =>
_cache.get(botId, forceRefresh: forceRefresh);
static BotInfo? peek(int botId) => _cache.peek(botId);
static List<BotCommand> commandsOf(int botId) =>
_cache.peek(botId)?.commands ?? const [];
static void invalidate(int botId) => _cache.invalidate(botId);
static void clear() => _cache.clear();
static Future<BotInfo?> _fetch(int botId) async {
final api = _api;
if (api == null || api.state != SessionState.online) return null;
final resp = await api.sendRequest(Opcode.botInfo, {'botId': botId});
final data = resp.payload;
if (data is! Map) return null;
final info = BotInfo.fromPayload(botId, Map<String, dynamic>.from(data));
final contact = info.contact;
if (contact != null) ContactInfoFetch.putContact(botId, contact.raw);
return info;
}
}
class ChatInfoFetch {
static final _cache = InfoCache<ChatInfo>(
ttl: const Duration(minutes: 5),
fetcher: _fetch,
);
static Future<ChatInfo?> get(int id, {bool forceRefresh = false}) =>
_cache.get(id, forceRefresh: forceRefresh);
static ChatInfo? peek(int id) => _cache.peek(id);
static void put(int id, ChatInfo info) {
ChatMembersStore.instance.applyChatPayload(info.raw);
_cache.putValue(id, info);
}
static void invalidate(int id) => _cache.invalidate(id);
static void clear() => _cache.clear();
static Future<ChatInfo?> _fetch(int id) async {
final api = _api;
if (api == null || api.state != SessionState.online) return null;
final resp = await api.sendRequest(Opcode.chatInfo, {
'chatIds': [id],
});
final data = resp.payload;
if (data is! Map) return null;
final chats = data['chats'];
if (chats is! List || chats.isEmpty) return null;
final first = chats.first;
if (first is! Map) return null;
ChatMembersStore.instance.applyChatPayload(first);
return ChatInfo.fromMap(Map<String, dynamic>.from(first));
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'dart:collection';
import '../../backend/modules/messages.dart';
class CachedChatMessages {
final List<CachedMessage> messages;
final bool reachedStart;
const CachedChatMessages(this.messages, this.reachedStart);
}
class MessageSessionCache {
static const int _capacity = 24;
static final LinkedHashMap<String, CachedChatMessages> _store =
LinkedHashMap<String, CachedChatMessages>();
static String _key(int accountId, int chatId) => '$accountId:$chatId';
static CachedChatMessages? get(int accountId, int chatId) {
final key = _key(accountId, chatId);
final entry = _store.remove(key);
if (entry == null) return null;
_store[key] = entry;
return entry;
}
static void save(
int accountId,
int chatId,
List<CachedMessage> messages, {
required bool reachedStart,
}) {
if (messages.isEmpty) return;
final key = _key(accountId, chatId);
_store.remove(key);
_store[key] = CachedChatMessages(
List<CachedMessage>.of(messages),
reachedStart,
);
while (_store.length > _capacity) {
_store.remove(_store.keys.first);
}
}
static void replace(
int accountId,
int chatId,
String messageId,
CachedMessage Function(CachedMessage current) change,
) {
final cached = get(accountId, chatId);
if (cached == null) return;
final list = List<CachedMessage>.of(cached.messages);
final idx = list.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
list[idx] = change(list[idx]);
save(accountId, chatId, list, reachedStart: cached.reachedStart);
}
static void append(int accountId, int chatId, List<CachedMessage> added) {
final cached = _store[_key(accountId, chatId)];
if (cached == null) return;
final known = {for (final message in cached.messages) message.id};
final fresh = [
for (final message in added)
if (!known.contains(message.id)) message,
];
if (fresh.isEmpty) return;
final merged = [...cached.messages, ...fresh]
..sort((a, b) => a.time.compareTo(b.time));
save(accountId, chatId, merged, reachedStart: cached.reachedStart);
}
static void remove(int accountId, int chatId) =>
_store.remove(_key(accountId, chatId));
static void clearAll() => _store.clear();
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/foundation.dart';
class SelfPresence {
static final ValueNotifier<bool> isOnline = ValueNotifier(true);
static final ValueNotifier<int?> lastSeenSeconds = ValueNotifier(null);
static int get _nowSeconds => DateTime.now().millisecondsSinceEpoch ~/ 1000;
static void markOnline() {
isOnline.value = true;
}
static void markOffline() {
isOnline.value = false;
}
static void markOfflineFromPing() {
if (isOnline.value || lastSeenSeconds.value == null) {
lastSeenSeconds.value = _nowSeconds;
}
isOnline.value = false;
}
static void applySelfCheck({required bool online, int? seenSeconds}) {
if (online) {
isOnline.value = true;
} else {
isOnline.value = false;
if (seenSeconds != null) lastSeenSeconds.value = seenSeconds;
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'call_session.dart';
class ActiveCallPresentation {
const ActiveCallPresentation({
required this.session,
required this.name,
required this.avatarUrl,
required this.isGroup,
});
final CallSession session;
final String name;
final String? avatarUrl;
final bool isGroup;
bool sameAs(ActiveCallPresentation other) =>
identical(session, other.session) &&
name == other.name &&
avatarUrl == other.avatarUrl &&
isGroup == other.isGroup;
}
class ActiveCall {
ActiveCall._();
static final ActiveCall instance = ActiveCall._();
final ValueNotifier<ActiveCallPresentation?> current = ValueNotifier(null);
final ValueNotifier<bool> screenVisible = ValueNotifier(false);
int _openScreens = 0;
StreamSubscription<CallSessionState>? _stateSub;
void attach({
required CallSession session,
required String name,
String? avatarUrl,
bool isGroup = false,
}) {
if (session.currentState == CallSessionState.ended) return;
final next = ActiveCallPresentation(
session: session,
name: name,
avatarUrl: avatarUrl,
isGroup: isGroup,
);
final previous = current.value;
if (previous != null && previous.sameAs(next)) return;
if (previous == null || !identical(previous.session, session)) {
_stateSub?.cancel();
_stateSub = session.stateStream.listen((state) {
if (state == CallSessionState.ended) detach(session);
});
}
_publish(current, next);
}
void detach([CallSession? session]) {
final active = current.value;
if (active == null) return;
if (session != null && !identical(active.session, session)) return;
_stateSub?.cancel();
_stateSub = null;
_publish(current, null);
}
void enterScreen() {
_openScreens++;
_publish(screenVisible, true);
}
void leaveScreen() {
if (_openScreens > 0) _openScreens--;
_publish(screenVisible, _openScreens > 0);
}
void _publish<T>(ValueNotifier<T> notifier, T value) {
if (SchedulerBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
notifier.value = value;
});
return;
}
notifier.value = value;
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../utils/logger.dart';
class AudioInputDevice {
const AudioInputDevice({required this.id, required this.label});
final String id;
final String label;
}
class AudioDevices {
AudioDevices._();
static Future<List<AudioInputDevice>> microphones() async {
try {
final devices = await navigator.mediaDevices.enumerateDevices();
final mics = <AudioInputDevice>[];
final seen = <String>{};
for (final device in devices) {
if (device.kind != 'audioinput') continue;
if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue;
mics.add(
AudioInputDevice(id: device.deviceId, label: device.label.trim()),
);
}
return mics;
} catch (e) {
logger.w('[call] enumerateDevices: $e');
return const [];
}
}
static bool get switchesInsideEngine =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS);
static Future<void> selectInput(String deviceId) async {
try {
await Helper.selectAudioInput(deviceId);
} catch (e) {
logger.w('[call] selectAudioInput($deviceId): $e');
}
}
static Object micConstraints(
String? deviceId, {
bool monitorCapture = false,
}) {
final constraints = <String, dynamic>{};
final hasDevice = deviceId != null && deviceId.isNotEmpty;
if (hasDevice && !switchesInsideEngine) {
if (kIsWeb) {
constraints['deviceId'] = deviceId;
} else {
constraints['optional'] = [
{'sourceId': deviceId},
];
}
}
if (monitorCapture) {
constraints['echoCancellation'] = true;
constraints['noiseSuppression'] = false;
constraints['autoGainControl'] = false;
constraints['highpassFilter'] = false;
}
return constraints.isEmpty ? true : constraints;
}
static Future<String?> findDevice(String token, {int attempts = 1}) async {
for (var attempt = 0; attempt < attempts; attempt++) {
for (final device in await microphones()) {
if (device.id == token ||
device.id.contains(token) ||
device.label.contains(token)) {
return device.id;
}
}
if (attempt + 1 < attempts) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
return null;
}
}
+298
View File
@@ -0,0 +1,298 @@
import 'ws2_signaling.dart';
enum CallMedia {
audio('AUDIO'),
video('VIDEO'),
screenShare('SCREEN_SHARING'),
movieShare('MOVIE_SHARING');
const CallMedia(this.wire);
final String wire;
}
enum CallMuteState {
unmute('UNMUTE'),
mute('MUTE'),
mutePermanent('MUTE_PERMANENT');
const CallMuteState(this.wire);
final String wire;
}
enum CallRoleName {
creator('CREATOR'),
admin('ADMIN'),
speaker('SPEAKER');
const CallRoleName(this.wire);
final String wire;
}
enum CallOption {
requireAuthToJoin('REQUIRE_AUTH_TO_JOIN'),
waitingHall('WAITING_HALL'),
recurring('RECURRING'),
feedback('FEEDBACK'),
audienceMode('AUDIENCE_MODE'),
asr('ASR'),
waitForAdmin('WAIT_FOR_ADMIN'),
adminIsHere('ADMIN_IS_HERE');
const CallOption(this.wire);
final String wire;
}
enum CallFeature {
addParticipant('ADD_PARTICIPANT'),
admin('ADMIN'),
asr('ASR'),
movieShare('MOVIE_SHARE'),
record('RECORD'),
speaker('SPEAKER');
const CallFeature(this.wire);
final String wire;
}
enum CallListType {
grid('GRID'),
side('SIDE');
const CallListType(this.wire);
final String wire;
}
class CallParticipantRef {
final int id;
final int deviceIdx;
final bool isGroup;
const CallParticipantRef(this.id, {this.deviceIdx = 0, this.isGroup = false});
String get wire => '${isGroup ? 'g' : 'u'}$id:d$deviceIdx';
}
class CallAdmin {
final Ws2Signaling _signaling;
const CallAdmin(this._signaling);
Future<void> requestMedia(
Set<CallMedia> media, {
CallParticipantRef? participant,
String? roomId,
}) {
return _signaling.sendCommand(
'mute-participant',
extra: {
'participantId': ?participant?.wire,
'requestedMedia': media.map((m) => m.wire).toList(),
'roomId': ?roomId,
},
);
}
Future<void> setMuteStates(
Map<CallMedia, CallMuteState?> states, {
CallParticipantRef? participant,
String? roomId,
}) {
return _signaling.sendCommand(
'mute-participant',
extra: {
'participantId': ?participant?.wire,
'muteStates': {
for (final media in CallMedia.values) media.wire: states[media]?.wire,
},
'roomId': ?roomId,
},
);
}
Future<void> muteMicrophone(
CallParticipantRef participant, {
bool muted = true,
}) {
return _signaling.sendCommand(
'switch-micro',
extra: {'eId': participant.wire, 'muteTarget': muted},
);
}
Future<void> muteEveryone() {
return _signaling.sendCommand(
'switch-micro',
extra: const {'all': true, 'muteTarget': true},
);
}
Future<void> setPromoted(CallParticipantRef participant, bool promoted) {
return _signaling.sendCommand(
'promote-participant',
extra: {'participantId': participant.wire, 'demote': !promoted},
);
}
Future<void> setRoles(
CallParticipantRef participant,
List<CallRoleName> roles, {
bool revoke = false,
}) {
return _signaling.sendCommand(
'grant-roles',
extra: {
'participantId': participant.wire,
'roles': roles.map((r) => r.wire).toList(),
'revoke': revoke,
},
);
}
Future<void> removeParticipant(CallParticipantRef participant) {
return _signaling.sendCommand(
'remove-participant',
extra: {'participantId': participant.wire},
);
}
Future<void> setPinned(
CallParticipantRef participant,
bool pinned, {
String? roomId,
}) {
return _signaling.sendCommand(
'pin-participant',
extra: {
'participantId': participant.wire,
'unpin': !pinned,
'roomId': ?roomId,
},
);
}
Future<void> setOptions(Map<CallOption, bool> options) {
return _signaling.sendCommand(
'change-options',
extra: {
'options': {
for (final entry in options.entries) entry.key.wire: entry.value,
},
},
);
}
Future<void> enableFeatureForRoles(
CallFeature feature,
List<CallRoleName> roles,
) {
return _signaling.sendCommand(
'enable-feature-for-roles',
extra: {
'feature': feature.wire,
'roles': roles.map((r) => r.wire).toList(),
},
);
}
Future<void> lowerAllHands() => _signaling.sendCommand('put-hands-down');
Future<void> setHandRaised(bool raised, {CallParticipantRef? participant}) {
return _setState({'hand': raised ? '1' : '0'}, participant: participant);
}
Future<void> setAssistanceRequested(
bool requested, {
CallParticipantRef? participant,
}) {
return _setState({'drat': requested ? '1' : '0'}, participant: participant);
}
Future<void> _setState(
Map<String, String> state, {
CallParticipantRef? participant,
}) {
return _signaling.sendCommand(
'change-participant-state',
extra: {
'participantState': {'state': state},
'participantId': ?participant?.wire,
},
);
}
Future<void> addParticipants(
List<String> externalIds, {
bool? unban,
bool showChatHistory = false,
}) {
return _signaling.sendCommand(
'add-participant',
extra: {
'externalIds': externalIds,
if (unban == true) 'unban': true,
if (showChatHistory) 'payload': '{"show_chat_history":true}',
},
);
}
Future<void> addParticipantByLink(String link) {
return _signaling.sendCommand(
'add-participant',
extra: {'participantIdAsQRCodeLink': link},
);
}
Future<Map<String, dynamic>> startRecord({
int? movieId,
String? name,
String? description,
String? privacy,
int? groupId,
String? albumId,
bool streamMovie = false,
String? roomId,
}) {
return _signaling.sendCommand(
'record-start',
extra: {
'movieId': movieId,
'name': name,
'description': description,
'privacy': privacy,
'groupId': groupId,
'albumId': albumId,
'streamMovie': streamMovie,
'roomId': ?roomId,
},
);
}
Future<void> stopRecord({bool remove = false, String? roomId}) {
return _signaling.sendCommand(
'record-stop',
extra: {if (remove) 'remove': true, 'roomId': ?roomId},
);
}
Future<Map<String, dynamic>> participantChunk({
int count = 50,
CallListType listType = CallListType.grid,
String? roomId,
}) {
return _signaling.sendCommand(
'get-participant-list-chunk',
extra: {'count': count, 'listType': listType.wire, 'roomId': ?roomId},
);
}
Future<Map<String, dynamic>> waitingHall({
int count = 50,
String? fromId,
bool backward = false,
}) {
return _signaling.sendCommand(
'get-waiting-hall',
extra: {'count': count, 'fromId': ?fromId, 'backward': backward},
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import '../utils/logger.dart';
import 'call_controller.dart';
class CallBridge {
CallBridge._();
static final CallBridge instance = CallBridge._();
static const _method = MethodChannel('ru.ikar.app/calls');
static const _events = EventChannel('ru.ikar.app/calls_events');
bool _started = false;
bool get _android {
try {
return Platform.isAndroid;
} catch (_) {
return false;
}
}
void init() {
if (_started || !_android) return;
_started = true;
_events.receiveBroadcastStream().listen(
_handle,
onError: (e) => logger.w('CallBridge.init: events stream error: $e'),
);
}
Future<void> checkInitialCall() async {
if (!_android) return;
try {
_handle(await _method.invokeMethod<dynamic>('consumeInitialCall'));
} catch (e) {
logger.w('CallBridge.checkInitialCall: $e');
}
}
void _handle(Object? event) {
if (event is! Map) return;
final action = event['action']?.toString();
if (action == 'hangup') {
unawaited(CallController.instance.endActive());
return;
}
if (action == 'ended') {
CallController.instance.dismissIncoming();
return;
}
final dataStr = event['data'];
if (dataStr is! String) return;
Object? decoded;
try {
decoded = jsonDecode(dataStr);
} catch (e) {
logger.w('CallBridge._handle: action=$action jsonDecode failed: $e');
return;
}
if (decoded is! Map) return;
CallController.instance.injectFromNative(
decoded,
autoAccept: action == 'answer',
);
}
Future<void> notifyAccepted({String? caller}) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyAccepted', {'caller': caller});
} catch (e) {
logger.w('CallBridge.notifyAccepted: caller=$caller $e');
}
}
Future<void> ensureOngoing({String? caller}) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('ensureOngoing', {'caller': caller});
} catch (e) {
logger.w('CallBridge.ensureOngoing: $e');
}
}
Future<void> setScreenShare(bool enabled, {String? caller}) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('setScreenShare', {
'enabled': enabled,
'caller': caller,
});
} catch (e) {
logger.w('CallBridge.setScreenShare: enabled=$enabled $e');
}
}
Future<void> dropOngoing() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('dropOngoing');
} catch (e) {
logger.w('CallBridge.dropOngoing: $e');
}
}
Future<void> notifyEnded() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyEnded');
} catch (e) {
logger.w('CallBridge.notifyEnded: $e');
}
}
Future<void> cancelIncoming() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('cancelIncoming');
} catch (e) {
logger.w('CallBridge.cancelIncoming: $e');
}
}
Future<bool> canUseFullScreenIntent() async {
if (!_android) return true;
try {
return await _method.invokeMethod<bool>('canUseFullScreenIntent') ?? true;
} catch (e) {
logger.w('CallBridge.canUseFullScreenIntent: $e');
return true;
}
}
Future<void> openFullScreenIntentSettings() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('openFullScreenIntentSettings');
} catch (e) {
logger.w('CallBridge.openFullScreenIntentSettings: $e');
}
}
}
+301
View File
@@ -0,0 +1,301 @@
import 'dart:async';
import '../../backend/api.dart';
import '../../backend/modules/calls.dart';
import '../protocol/opcode_map.dart';
import '../push/fkm_controller.dart';
import '../protocol/packet.dart';
import '../utils/parse.dart';
import 'call_bridge.dart';
import 'call_session.dart';
import 'conversation_params.dart';
import 'ws2_signaling.dart';
class IncomingCall {
final String conversationId;
final int callerId;
final bool isVideo;
final ConversationParams params;
final String? country;
final bool? isContact;
final String? callerName;
final bool autoAccept;
const IncomingCall({
required this.conversationId,
required this.callerId,
required this.isVideo,
required this.params,
this.country,
this.isContact,
this.callerName,
this.autoAccept = false,
});
}
class CallController {
CallController._();
static final CallController instance = CallController._();
Api? _api;
CallsModule? _calls;
StreamSubscription<Packet>? _pushSub;
final _incoming = StreamController<IncomingCall>.broadcast();
final _ended = StreamController<void>.broadcast();
final _canceled = StreamController<void>.broadcast();
bool appResumed = false;
Stream<IncomingCall> get incomingCalls => _incoming.stream;
Stream<void> get callEnded => _ended.stream;
Stream<void> get incomingCanceled => _canceled.stream;
CallSession? _active;
StreamSubscription<CallSessionState>? _activeSub;
CallSession? get activeSession => _active;
String? _activeJoinLink;
String? get activeJoinLink => _active == null ? null : _activeJoinLink;
bool _starting = false;
IncomingCall? _pending;
IncomingCall? get pendingIncoming => _pending;
bool get isBusy => _active != null || _starting;
void init(Api api) {
if (_api != null) return;
_api = api;
_calls = CallsModule(api);
_pushSub = api.pushStream.listen(_onPush);
}
void _onPush(Packet packet) {
if (packet.opcode != Opcode.notifCallStart) return;
final payload = packet.payload;
if (payload is! Map) return;
final vcp = payload['vcp'] as String?;
final conversationId = payload['conversationId'] as String?;
final callerId = payload['callerId'] as int?;
if (vcp == null || conversationId == null || callerId == null) return;
// Приложение свёрнуто — звонок показывает FKM отдельным уведомлением,
// приём оттуда вернётся через injectFromNative.
if (!appResumed) {
unawaited(FkmController.instance.showIncomingCall(payload));
return;
}
final params = ConversationParams.decode(vcp);
if (params == null) return;
_emitIncoming(
IncomingCall(
conversationId: conversationId,
callerId: callerId,
isVideo: payload['type'] == 'VIDEO' || params.isVideo,
params: params,
country: payload['country'] as String?,
isContact: payload['isContact'] as bool?,
),
);
}
void injectFromNative(Map<dynamic, dynamic> data, {bool autoAccept = false}) {
final vcp = data['vcp']?.toString();
if (vcp == null || vcp.isEmpty) return;
final params = ConversationParams.decode(vcp);
if (params == null) return;
final conversationId = (data['conversationId'] ?? data['vcId'])?.toString();
if (conversationId == null || conversationId.isEmpty) return;
final callerId = parseIntOrNull(data['callerId'] ?? data['suid']);
if (callerId == null) return;
final type = (data['type'] ?? data['callType'])?.toString();
final iv = data['iv'];
final isVideo =
params.isVideo || type == 'VIDEO' || iv == true || iv == 'true';
_emitIncoming(
IncomingCall(
conversationId: conversationId,
callerId: callerId,
isVideo: isVideo,
params: params,
country: data['country']?.toString(),
isContact: data['isContact'] is bool ? data['isContact'] as bool : null,
callerName: data['userName']?.toString(),
autoAccept: autoAccept,
),
);
}
void _emitIncoming(IncomingCall incoming) {
if (isBusy) return;
if (_pending?.conversationId == incoming.conversationId) return;
_pending = incoming;
_incoming.add(incoming);
}
void dismissIncoming() {
if (_pending == null) return;
_pending = null;
_canceled.add(null);
}
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) =>
_startExclusive(() async {
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
final config = Ws2Config.fromEndpoint(
out.endpoint,
userId: out.callsUserId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
);
final session = CallSession(ws2Config: config, role: CallRole.caller);
return _launch(session, session.start);
});
// #***! конференция со ссылкой
Future<CreatedCall> createConference() async {
if (isBusy) throw StateError('уже идёт звонок');
return _calls!.createConference();
}
Future<CallLinkPreview?> previewCallLink(String url) =>
_calls!.resolveCallLink(url);
Future<CallSession> joinByLink(String token, {bool isVideo = false}) =>
_startExclusive(() async {
final params = await _calls!.joinByLink(token, isVideo: isVideo);
final config = Ws2Config.fromEndpoint(
params.endpoint,
userId: params.callsUserId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
);
final session = CallSession(
ws2Config: config,
role: CallRole.joiner,
isGroup: true,
);
_activeJoinLink = token;
return _launch(session, session.start);
});
Future<CallSession> _startExclusive(
Future<CallSession> Function() start,
) async {
if (isBusy) throw StateError('уже идёт звонок');
_starting = true;
try {
return await start();
} finally {
_starting = false;
}
}
Future<CallSession> acceptIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.cancelIncoming();
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
);
final session = CallSession(
ws2Config: config,
params: call.params,
role: CallRole.callee,
);
return _launch(session, () async {
await session.start();
await session.accept();
}, caller: call.callerName);
}
Future<void> rejectIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.notifyEnded();
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
);
final signaling = Ws2Signaling(config);
try {
await signaling.connect();
await signaling.hangup(reason: 'REJECTED');
} catch (_) {
} finally {
await signaling.close();
}
}
Future<void> endActive() => _active?.hangup() ?? Future.value();
Future<bool> sendMicSignal(bool enabled) async {
final session = _active;
if (session == null) return false;
await session.sendAudioEnabledSignal(enabled);
return true;
}
Future<CallSession> _launch(
CallSession session,
Future<void> Function() open, {
String? caller,
}) async {
_bind(session);
try {
await open();
} catch (_) {
await _release(session);
try {
await session.hangup();
} catch (_) {}
rethrow;
}
CallBridge.instance.notifyAccepted(caller: caller);
return session;
}
void _bind(CallSession session) {
unawaited(_activeSub?.cancel());
_active = session;
_activeSub = session.stateStream.listen((state) {
if (state != CallSessionState.ended) return;
unawaited(_release(session));
});
}
Future<void> _release(CallSession session) async {
if (!identical(_active, session)) return;
_active = null;
_activeJoinLink = null;
await _activeSub?.cancel();
_activeSub = null;
CallBridge.instance.notifyEnded();
_ended.add(null);
}
void dispose() {
_activeSub?.cancel();
_pushSub?.cancel();
_incoming.close();
_ended.close();
_canceled.close();
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:convert';
class CallInfo {
String? conversationId;
String? topology;
String? peerPlatform;
String? peerEngine;
String? peerIp;
String? peerNetwork;
String? path;
String? audioCodec;
bool record = false;
bool denoise = false;
bool animoji = false;
String? region;
String? dtlsFingerprint;
final List<String> stun = [];
final List<String> turn = [];
}
class CallParse {
static Map<String, String> candidate(String c) {
final parts = c.trim().split(RegExp(r'\s+'));
String? at(int i) => (i >= 0 && i < parts.length) ? parts[i] : null;
int idx(String k) => parts.indexOf(k);
final r = <String, String>{};
final tr = at(2);
if (tr != null) r['transport'] = tr.toUpperCase();
final ip = at(4);
if (ip != null) r['ip'] = ip;
final port = at(5);
if (port != null) r['port'] = port;
final typ = idx('typ');
if (typ != -1) r['type'] = at(typ + 1) ?? '';
final nc = idx('network-cost');
if (nc != -1) r['cost'] = at(nc + 1) ?? '';
return r;
}
static String networkLabel(String? cost) {
switch (cost) {
case '0':
return 'VPN';
case '10':
return 'Wi-Fi / Ethernet';
case '50':
return 'неизвестно';
case '900':
case '999':
return 'сотовая';
default:
return (cost == null || cost.isEmpty) ? '—' : 'cost=$cost';
}
}
static String engine(String sdp) {
for (final line in const LineSplitter().convert(sdp)) {
if (!line.startsWith('o=')) continue;
final l = line.toLowerCase();
if (l.contains('mozilla') || l.contains('sdparta'))
return 'Firefox (web)';
if (l.contains('gstreamer')) return 'GStreamer';
return 'нативный libwebrtc';
}
return 'неизвестно';
}
static String? audioCodec(String sdp) {
for (final line in const LineSplitter().convert(sdp)) {
if (line.startsWith('a=rtpmap:') && line.toLowerCase().contains('opus')) {
final i = line.indexOf(' ');
if (i != -1) return line.substring(i + 1).trim();
}
}
return null;
}
static String? fingerprint(String sdp) {
for (final line in const LineSplitter().convert(sdp)) {
if (line.startsWith('a=fingerprint:')) {
return line.substring('a=fingerprint:'.length).trim();
}
}
return null;
}
static bool hasAnimoji(String sdp) => sdp.contains('animoji');
static bool isServerIp(String ip) => ip.startsWith('155.212.');
static String pathLabel(String? localType, String? remoteType) {
final relay = localType == 'relay' || remoteType == 'relay';
final via = relay ? 'через сервер (TURN)' : 'прямое (P2P)';
String t(String? v) => v ?? '?';
return '$via · ${t(localType)} ↔ ${t(remoteType)}';
}
}
+26
View File
@@ -0,0 +1,26 @@
class CallLink {
static const String base = 'https://ikar.kusoft.xyz/joincall/';
static final RegExp _pattern = RegExp(
r'^https?://(?:[^/\s]+\.)?max\.ru/joincall/([A-Za-z0-9_-]+)',
caseSensitive: false,
);
static final RegExp _rawPattern = RegExp(
r'^(?:joincall/)?([A-Za-z0-9_-]+)$',
caseSensitive: false,
);
static bool isCallLink(String url) => token(url) != null;
static String? token(String url) => _pattern.firstMatch(url.trim())?.group(1);
static String? normalizeToken(String raw) {
final value = raw.trim();
return token(value) ?? _rawPattern.firstMatch(value)?.group(1);
}
static String url(String token) => '$base$token';
static String path(String token) => 'joincall/$token';
}
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../utils/logger.dart';
enum CameraFacing { front, back, external }
class VideoInputDevice {
const VideoInputDevice({
required this.id,
required this.label,
required this.facing,
});
final String id;
final String label;
final CameraFacing facing;
}
class CameraDevices {
CameraDevices._();
static Future<List<VideoInputDevice>> cameras() async {
try {
final devices = await navigator.mediaDevices.enumerateDevices();
final cameras = <VideoInputDevice>[];
final seen = <String>{};
for (final device in devices) {
if (device.kind != 'videoinput') continue;
if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue;
final label = device.label.trim();
cameras.add(
VideoInputDevice(
id: device.deviceId,
label: label,
facing: facingOf(label),
),
);
}
return cameras;
} catch (e) {
logger.w('[media] enumerate cameras: $e');
return const [];
}
}
static CameraFacing facingOf(String label) {
final lower = label.toLowerCase();
if (lower.contains('front') || lower.contains('user')) {
return CameraFacing.front;
}
if (lower.contains('back') ||
lower.contains('rear') ||
lower.contains('environment')) {
return CameraFacing.back;
}
return CameraFacing.external;
}
static Object constraints(String? deviceId) {
if (deviceId == null || deviceId.isEmpty) return true;
if (kIsWeb) return <String, dynamic>{'deviceId': deviceId};
return <String, dynamic>{
'optional': [
{'sourceId': deviceId},
],
};
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:kusoft/kusoft.dart' as kb;
/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше
/// входящего звонка (opcode 137) и в ответе на инициацию исходящего.
///
/// Формат строки: `<rawLen>:<base64(LZ4-block)>`. После распаковки —
/// компактный JSON с короткими ключами. Расшифровка повторяет
/// `ru.ok.android.externcalls.sdk.api.ConversationParams.decode`.
class ConversationParams {
/// Токен авторизации в сигналинге звонка.
final String token;
/// WebSocket сигналинга, напр. `wss://videowebrtc.okcdn.ru/ws2`.
final String wsEndpoint;
final List<String> wsIps;
/// HTTP/3 web-transport fallback, напр. `https://videowebrtc.okcdn.ru:23456/wt`.
final String? wtEndpoint;
final List<String> wtIps;
/// API звонков, напр. `https://calls.okcdn.ru`.
final String? callsApiEndpoint;
final List<String> callsApiIps;
/// Тип клиента, напр. `one_me`.
final String? clientType;
/// Время истечения параметров (unix-секунды).
final int? expiresAt;
final String? stun;
final List<String> turn;
final String? turnUser;
final String? turnPassword;
final bool isVideo;
const ConversationParams({
required this.token,
required this.wsEndpoint,
this.wsIps = const [],
this.wtEndpoint,
this.wtIps = const [],
this.callsApiEndpoint,
this.callsApiIps = const [],
this.clientType,
this.expiresAt,
this.stun,
this.turn = const [],
this.turnUser,
this.turnPassword,
this.isVideo = false,
});
/// ICE-серверы в формате, который ожидает `flutter_webrtc`
/// (`RTCPeerConnection`).
List<Map<String, dynamic>> get iceServers {
final servers = <Map<String, dynamic>>[];
if (stun != null && stun!.isNotEmpty) {
servers.add({'urls': stun});
}
if (turn.isNotEmpty) {
servers.add({
'urls': turn,
if (turnUser != null) 'username': turnUser,
if (turnPassword != null) 'credential': turnPassword,
});
}
return servers;
}
/// `true`, если параметры ещё действительны (с запасом в 5 секунд).
bool get isExpired {
if (expiresAt == null) return false;
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return nowSec >= expiresAt! - 5;
}
/// Распаковывает и парсит строку `vcp` через Rust-ядро (kusoft). Возвращает
/// `null`, если формат не распознан. Требует инициализации `initKusoft()`.
static ConversationParams? decode(String vcp) {
final kb.CallParams? p = kb.decodeVcp(vcp: vcp, conversationId: '');
if (p == null) return null;
return ConversationParams(
token: p.token,
wsEndpoint: p.wsEndpoint,
stun: p.stun,
turn: p.turn,
turnUser: p.turnUser,
turnPassword: p.turnPassword,
isVideo: p.isVideo,
);
}
}
+168
View File
@@ -0,0 +1,168 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../utils/logger.dart';
class PulseSource {
const PulseSource({
required this.name,
required this.label,
required this.isMonitor,
});
final String name;
final String label;
final bool isMonitor;
}
class PulseRouteException implements Exception {
const PulseRouteException(this.source);
final String source;
@override
String toString() => source;
}
class PulseAudio {
PulseAudio._();
static const String bridgePrefix = 'qlyra_capture_';
static String? _bridgeModule;
static String? _bridgeMaster;
static String? _bridgeSource;
static bool get supported => !kIsWeb && Platform.isLinux;
static String get _bridgeName => '$bridgePrefix$pid';
static Future<ProcessResult?> _pactl(List<String> args) async {
if (!supported) return null;
try {
return await Process.run(
'pactl',
args,
stdoutEncoding: utf8,
stderrEncoding: utf8,
);
} on ProcessException catch (e) {
logger.w('[call][pulse] pactl ${args.first}: ${e.message}');
return null;
}
}
static Future<bool> isAvailable() async =>
(await _pactl(const ['info']))?.exitCode == 0;
static Future<List<PulseSource>> sources() async {
final result = await _pactl(const ['-f', 'json', 'list', 'sources']);
if (result == null || result.exitCode != 0) return const [];
return parseSources(result.stdout as String);
}
static List<PulseSource> parseSources(String json) {
final List<dynamic> entries;
try {
entries = jsonDecode(json) as List<dynamic>;
} catch (e) {
logger.w('[call][pulse] список источников: $e');
return const [];
}
final sources = <PulseSource>[];
for (final entry in entries.whereType<Map<String, dynamic>>()) {
final name = entry['name'];
if (name is! String || name.isEmpty) continue;
if (name.startsWith(bridgePrefix)) continue;
final monitorOf = entry['monitor_source'];
final isMonitor = monitorOf is String && monitorOf.isNotEmpty;
sources.add(
PulseSource(
name: name,
label: _labelOf(entry, name, isMonitor),
isMonitor: isMonitor,
),
);
}
return sources;
}
static Future<PulseSource?> find(String name) async {
for (final source in await sources()) {
if (source.name == name) return source;
}
return null;
}
static Future<String?> openBridge(String master) async {
if (_bridgeMaster == master && _bridgeSource != null) return _bridgeSource;
await closeBridge();
await _dropStaleBridges();
final name = _bridgeName;
final result = await _pactl([
'load-module',
'module-remap-source',
'master=$master',
'source_name=$name',
'source_properties=device.description=$name',
]);
if (result == null || result.exitCode != 0) {
logger.w('[call][pulse] remap-source($master): ${result?.stderr}');
return null;
}
final module = (result.stdout as String).trim();
if (module.isEmpty) return null;
_bridgeModule = module;
_bridgeMaster = master;
_bridgeSource = name;
logger.i('[call][pulse] мост $name ← $master (модуль $module)');
return name;
}
static Future<void> closeBridge() async {
final module = _bridgeModule;
_bridgeModule = null;
_bridgeMaster = null;
_bridgeSource = null;
if (module == null) return;
await _pactl(['unload-module', module]);
}
static Future<void> _dropStaleBridges() async {
final result = await _pactl(const ['list', 'modules', 'short']);
if (result == null || result.exitCode != 0) return;
for (final line in const LineSplitter().convert(result.stdout as String)) {
final columns = line.split('\t');
if (columns.length < 3) continue;
final owner = _bridgeOwnerPid(columns[2]);
if (owner == null || Directory('/proc/$owner').existsSync()) continue;
logger.i('[call][pulse] снимаю зависший мост процесса $owner');
await _pactl(['unload-module', columns[0]]);
}
}
static int? _bridgeOwnerPid(String argument) {
final match = RegExp('$bridgePrefix([0-9]+)').firstMatch(argument);
return match == null ? null : int.tryParse(match.group(1)!);
}
static String _labelOf(
Map<String, dynamic> entry,
String name,
bool isMonitor,
) {
final description = entry['description'];
if (_usable(description)) return description as String;
final properties = entry['properties'];
final device = properties is Map ? properties['device.description'] : null;
if (_usable(device)) {
return isMonitor ? 'Monitor of $device' : device as String;
}
return name;
}
static bool _usable(Object? value) =>
value is String && value.isNotEmpty && value != '(null)';
}
+346
View File
@@ -0,0 +1,346 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../utils/logger.dart';
class MsgpackWriter {
final BytesBuilder _out = BytesBuilder();
Uint8List takeBytes() => _out.takeBytes();
void raw(int byte) => _out.addByte(byte);
void nil() => raw(0xC0);
void boolean(bool value) => raw(value ? 0xC3 : 0xC2);
void integer(int value) {
if (value >= 0) {
if (value < 0x80) return raw(value);
if (value <= 0xFF) {
raw(0xCC);
return raw(value);
}
if (value <= 0xFFFF) {
raw(0xCD);
return _uint(value, 2);
}
if (value <= 0xFFFFFFFF) {
raw(0xCE);
return _uint(value, 4);
}
raw(0xCF);
return _uint(value, 8);
}
if (value >= -32) return raw(0xE0 | (value + 32));
if (value >= -128) {
raw(0xD0);
return _uint(value & 0xFF, 1);
}
if (value >= -32768) {
raw(0xD1);
return _uint(value & 0xFFFF, 2);
}
if (value >= -2147483648) {
raw(0xD2);
return _uint(value & 0xFFFFFFFF, 4);
}
raw(0xD3);
_uint(value, 8);
}
void string(String value) {
final bytes = utf8.encode(value);
final length = bytes.length;
if (length < 32) {
raw(0xA0 | length);
} else if (length <= 0xFF) {
raw(0xD9);
raw(length);
} else if (length <= 0xFFFF) {
raw(0xDA);
_uint(length, 2);
} else {
raw(0xDB);
_uint(length, 4);
}
_out.add(bytes);
}
void arrayHeader(int length) {
if (length < 16) return raw(0x90 | length);
if (length <= 0xFFFF) {
raw(0xDC);
return _uint(length, 2);
}
raw(0xDD);
_uint(length, 4);
}
void _uint(int value, int bytes) {
for (var shift = (bytes - 1) * 8; shift >= 0; shift -= 8) {
raw((value >> shift) & 0xFF);
}
}
}
class MsgpackReader {
final Uint8List _data;
int _pos = 0;
MsgpackReader(this._data);
bool get exhausted => _pos >= _data.length;
bool get nextIsString {
final b = _data[_pos];
return (b & 0xE0) == 0xA0 || b == 0xD9 || b == 0xDA || b == 0xDB;
}
int readInt() {
final b = _data[_pos++];
if (b < 0x80) return b;
if (b >= 0xE0) return b - 256;
switch (b) {
case 0xCC:
return _uint(1);
case 0xCD:
return _uint(2);
case 0xCE:
return _uint(4);
case 0xCF:
return _uint(8);
case 0xD0:
final v = _uint(1);
return v >= 0x80 ? v - 0x100 : v;
case 0xD1:
final v = _uint(2);
return v >= 0x8000 ? v - 0x10000 : v;
case 0xD2:
final v = _uint(4);
return v >= 0x80000000 ? v - 0x100000000 : v;
case 0xD3:
return _uint(8);
}
throw FormatException('не целое: 0x${b.toRadixString(16)}');
}
String readString() {
final b = _data[_pos++];
int length;
if ((b & 0xE0) == 0xA0) {
length = b & 0x1F;
} else if (b == 0xD9) {
length = _uint(1);
} else if (b == 0xDA) {
length = _uint(2);
} else if (b == 0xDB) {
length = _uint(4);
} else {
throw FormatException('не строка: 0x${b.toRadixString(16)}');
}
final value = utf8.decode(_data.sublist(_pos, _pos + length));
_pos += length;
return value;
}
int readMapHeader() {
final b = _data[_pos++];
if ((b & 0xF0) == 0x80) return b & 0x0F;
if (b == 0xDE) return _uint(2);
if (b == 0xDF) return _uint(4);
throw FormatException('не map: 0x${b.toRadixString(16)}');
}
int readArrayHeader() {
final b = _data[_pos++];
if ((b & 0xF0) == 0x90) return b & 0x0F;
if (b == 0xDC) return _uint(2);
if (b == 0xDD) return _uint(4);
throw FormatException('не array: 0x${b.toRadixString(16)}');
}
int _uint(int bytes) {
var value = 0;
for (var i = 0; i < bytes; i++) {
value = (value << 8) | _data[_pos++];
}
return value;
}
}
class SfuLayoutItem {
final String trackKey;
final int width;
final int height;
const SfuLayoutItem({
required this.trackKey,
this.width = 640,
this.height = 360,
});
}
class SfuCommandChannel {
static const int _commandDisplayLayout = 0;
static const int _fitMode = 0;
static const int _notifyAliases = 1;
static const int _notifySlots = 2;
static const int _notifyAudioLevels = 6;
RTCDataChannel? _command;
int _sequence = 1;
final Map<int, String> _aliases = {};
final _slots = StreamController<Map<String, int>>.broadcast();
final _levels = StreamController<Map<String, int>>.broadcast();
Stream<Map<String, int>> get slotUpdates => _slots.stream;
Stream<Map<String, int>> get audioLevels => _levels.stream;
void bind(RTCDataChannel channel) {
if (channel.label == 'producerCommand') {
_command = channel;
channel.onMessage = _onCommandReply;
return;
}
if (channel.label != 'producerNotification') return;
channel.onMessage = _onNotification;
}
bool get ready => _command?.state == RTCDataChannelState.RTCDataChannelOpen;
Future<bool> sendDisplayLayout(
List<SfuLayoutItem> items, {
bool snapshot = true,
}) async {
final channel = _command;
if (channel == null) return false;
if (channel.state != RTCDataChannelState.RTCDataChannelOpen) {
logger.w('[call][sfu] producerCommand не открыт, слои не отправлены');
return false;
}
final writer = MsgpackWriter()
..integer(_commandDisplayLayout)
..integer(0)
..integer(_sequence++)
..boolean(snapshot);
if (items.isEmpty) {
writer.nil();
} else {
writer.arrayHeader(items.length * 2);
for (final item in items) {
writer
..string(item.trackKey)
..integer(0)
..nil()
..integer(item.width)
..integer(item.height)
..integer(_fitMode);
}
}
writer.nil();
final payload = writer.takeBytes();
try {
await channel.send(RTCDataChannelMessage.fromBinary(payload));
logger.i(
'[call][sfu] update-display-layout: '
'${items.map((i) => i.trackKey).join(', ')} '
'raw=${payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
return true;
} catch (e) {
logger.w('[call][sfu] update-display-layout failed: $e');
return false;
}
}
void _onCommandReply(RTCDataChannelMessage message) {
if (!message.isBinary) return;
final bytes = message.binary;
final head = bytes.length > 32 ? bytes.sublist(0, 32) : bytes;
final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
try {
final reader = MsgpackReader(bytes);
final type = reader.readInt();
final version = reader.readInt();
final error = reader.readInt();
if (error != 0) {
logger.w(
'[call][sfu] command reply type=$type version=$version '
'ERROR=$error raw=$hex',
);
return;
}
logger.i('[call][sfu] command reply type=$type ok raw=$hex');
} catch (e) {
logger.w('[call][sfu] command reply parse failed: $e raw=$hex');
}
}
int _dumped = 0;
void _onNotification(RTCDataChannelMessage message) {
if (!message.isBinary) return;
if (_dumped < 12) {
_dumped++;
final bytes = message.binary;
final head = bytes.length > 64 ? bytes.sublist(0, 64) : bytes;
logger.i(
'[call][sfu] notify raw len=${bytes.length} '
'${head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
}
final bytes = message.binary;
if (bytes.isEmpty) return;
final type = bytes[0];
final reader = MsgpackReader(Uint8List.sublistView(bytes, 1));
try {
switch (type) {
case _notifyAliases:
final count = reader.readMapHeader();
for (var i = 0; i < count; i++) {
final key = reader.readString();
_aliases[reader.readInt()] = key;
}
break;
case _notifySlots:
final count = reader.readArrayHeader();
final slots = <String, int>{};
for (var i = 0; i < count; i++) {
final key = _aliases[reader.readInt()];
if (key != null) slots[key] = i;
}
logger.i('[call][sfu] slots: $slots');
if (!_slots.isClosed) _slots.add(slots);
break;
case _notifyAudioLevels:
final count = reader.readMapHeader();
final levels = <String, int>{};
for (var i = 0; i < count; i++) {
final key = _aliases[reader.readInt()];
final level = reader.readInt();
if (key != null) levels[key] = level;
}
if (!_levels.isClosed) _levels.add(levels);
break;
}
} catch (e) {
logger.w('[call][sfu] notify type=$type parse failed: $e');
}
}
Future<void> dispose() async {
_command = null;
_aliases.clear();
if (!_slots.isClosed) await _slots.close();
if (!_levels.isClosed) await _levels.close();
}
}
+360
View File
@@ -0,0 +1,360 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../transport/ikar_network.dart';
import 'conversation_params.dart';
/// Параметры подключения к сигналинг-сокету ws2.
///
/// Строится из двух источников:
/// - входящий звонок: [Ws2Config.fromVcp] (параметры из `vcp` пуша opcode 137);
/// - исходящий звонок: [Ws2Config.fromEndpoint] (`endpoint` из ответа opcode 78,
/// в нём уже вшит токен — дописываем только клиентские параметры).
class Ws2Config {
/// Готовый URL подключения к ws2.
final Uri uri;
/// Внутренний id пользователя в системе звонков.
final int userId;
const Ws2Config({required this.uri, required this.userId});
static const defaultCapabilities = '3c02f';
static const _appVersion = 'sdk-0.2.1.3';
static const defaultDevice = 'Android/Unknown';
static const defaultOsVersion = '34';
/// Входящий звонок: из распакованных параметров [ConversationParams].
/// `userId` — часть после `:` в [ConversationParams.turnUser].
factory Ws2Config.fromVcp(
ConversationParams params, {
required String conversationId,
String capabilities = defaultCapabilities,
String? device,
String? osVersion,
}) {
final userId = int.tryParse((params.turnUser ?? '').split(':').last) ?? 0;
final uri = Uri.parse(params.wsEndpoint).replace(
queryParameters: {
'userId': '$userId',
'token': params.token,
'conversationId': conversationId,
'version': '5',
'capabilities': capabilities,
'device': device ?? defaultDevice,
'platform': 'ANDROID',
'clientType': 'ONE_ME',
'appVersion': _appVersion,
'osVersion': osVersion ?? defaultOsVersion,
},
);
return Ws2Config(uri: uri, userId: userId);
}
/// Исходящий звонок или вход в конференцию: `endpoint` из ответа opcode 78 /
/// 166 уже содержит токен и conversationId/userId в query — дописываем
/// клиентские параметры. Без `tgt=start` медиасервер принимает сокет, но не
/// поднимает разговор и не шлёт нотификацию `connection`.
factory Ws2Config.fromEndpoint(
String endpoint, {
required int userId,
String capabilities = defaultCapabilities,
String? device,
String? osVersion,
}) {
final base = Uri.parse(endpoint);
final uri = base.replace(
queryParameters: {
...base.queryParameters,
'version': '5',
'capabilities': capabilities,
'device': device ?? defaultDevice,
'platform': 'ANDROID',
'clientType': 'ONE_ME',
'appVersion': _appVersion,
'osVersion': osVersion ?? defaultOsVersion,
'tgt': 'start',
},
);
return Ws2Config(uri: uri, userId: userId);
}
}
/// Ошибка, которую вернул сервер в ответе на команду.
class Ws2CommandException implements Exception {
final String command;
final Object? error;
Ws2CommandException(this.command, this.error);
@override
String toString() => 'Ws2CommandException($command): $error';
}
/// Клиент сигналинга звонка поверх WebSocket `ws2`.
///
/// Тонкий адаптер над Rust-ядром (kusoft [kb.CallSignaling]): ядро держит
/// WebSocket, корреляцию `sequence`/`response`, keepalive `ping`→`pong` и
/// разбор кадров; здесь — прежний Dart-интерфейс для [call_session].
///
/// Конверт сообщений:
/// - запрос: `{"command": ..., ..., "sequence": N}`
/// - ответ: `{"sequence": N, "response": "<command>", "type": "response"}`
/// - пуш: `{..., "notification": "<name>", "type": "notification"}`
class Ws2Signaling {
final Ws2Config config;
WebSocket? _call;
StreamSubscription<dynamic>? _notifSub;
int _sequence = 0;
final _requests = <int, Completer<Map<String, dynamic>>>{};
final _earlyNotifications = <Map<String, dynamic>>[];
final _notifications = StreamController<Map<String, dynamic>>.broadcast();
final _closed = Completer<Object?>();
Ws2Signaling(this.config) {
_notifications.onListen = () {
for (final item in _earlyNotifications) {
_notifications.add(item);
}
_earlyNotifications.clear();
};
}
/// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`.
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
/// Завершается, когда сокет закрыт.
Future<Object?> get done => _closed.future;
bool get isConnected => _call?.readyState == WebSocket.open;
Future<void> connect() async {
final call = await IkarNetwork.connectWebSocket(
config.uri.toString(),
headers: {'User-Agent': 'IKAR/1.0.0'},
);
_call = call;
_notifSub = call.listen(
(frame) {
Object? decoded;
try {
decoded = jsonDecode(
frame is String ? frame : utf8.decode(frame as List<int>),
);
} catch (_) {
return;
}
if (decoded is! Map<String, dynamic>) return;
if (decoded['type'] == 'response') {
final waiter = _requests.remove(
(decoded['sequence'] as num?)?.toInt(),
);
if (waiter != null && !waiter.isCompleted) waiter.complete(decoded);
} else if (decoded['type'] == 'notification') {
if (_notifications.hasListener) {
_notifications.add(decoded);
} else if (_earlyNotifications.length < 256) {
_earlyNotifications.add(decoded);
}
}
},
onError: (_) => _onDone(null),
onDone: () => _onDone(null),
cancelOnError: false,
);
}
void _onDone(Object? error) {
for (final waiter in _requests.values) {
if (!waiter.isCompleted)
waiter.completeError(error ?? StateError('Звонок завершён'));
}
_requests.clear();
if (!_closed.isCompleted) _closed.complete(error);
if (!_notifications.isClosed) _notifications.close();
}
/// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException],
/// если сервер вернул ошибку.
Future<Map<String, dynamic>> sendCommand(
String command, {
Map<String, dynamic> extra = const {},
Duration timeout = const Duration(seconds: 15),
}) async {
final call = _call;
if (call == null) {
return Future.error(StateError('ws2 не подключён'));
}
try {
final sequence = ++_sequence;
final waiter = Completer<Map<String, dynamic>>();
_requests[sequence] = waiter;
call.add(
jsonEncode({
'type': 'request',
'command': command,
'sequence': sequence,
...extra,
}),
);
try {
return await waiter.future.timeout(timeout);
} finally {
_requests.remove(sequence);
}
} catch (e) {
throw Ws2CommandException(command, e);
}
}
/// Передаёт SDP (offer/answer) другому участнику.
Future<void> transmitSdp({
required int participantId,
required String type,
required String sdp,
String participantType = 'USER',
int deviceIdx = 0,
String capabilities = Ws2Config.defaultCapabilities,
}) {
return sendCommand(
'transmit-data',
extra: {
'participantId': participantId,
'participantType': participantType,
'deviceIdx': deviceIdx,
'data': {
'sdp': {'type': type, 'sdp': sdp},
},
'capabilities': capabilities,
},
);
}
/// Передаёт ICE-кандидата другому участнику (trickle).
Future<void> transmitCandidate({
required int participantId,
required String candidate,
required String sdpMid,
required int sdpMLineIndex,
String participantType = 'USER',
int deviceIdx = 0,
}) {
return sendCommand(
'transmit-data',
extra: {
'participantId': participantId,
'participantType': participantType,
'deviceIdx': deviceIdx,
'data': {
'candidate': {
'candidate': candidate,
'sdpMid': sdpMid,
'sdpMLineIndex': sdpMLineIndex,
},
},
},
);
}
Future<void> changeMediaSettings({
bool isAudioEnabled = true,
bool isVideoEnabled = false,
bool isScreenSharingEnabled = false,
bool isAnimojiEnabled = false,
bool? isFastScreenSharingEnabled,
bool? isAudioSharingEnabled,
}) {
return sendCommand(
'change-media-settings',
extra: {
'mediaSettings': {
'isVideoEnabled': isVideoEnabled,
'isAudioEnabled': isAudioEnabled,
'isScreenSharingEnabled': isScreenSharingEnabled,
'isAnimojiEnabled': isAnimojiEnabled,
'isFastScreenSharingEnabled': ?isFastScreenSharingEnabled,
'isAudioSharingEnabled': ?isAudioSharingEnabled,
},
},
);
}
Future<void> switchTopology({
String topology = 'SERVER',
bool force = false,
}) {
return sendCommand(
'switch-topology',
extra: {'topology': topology, 'force': force},
);
}
Future<void> requestRealloc() => sendCommand('request-realloc');
/// Принять входящий звонок (сторона вызываемого).
Future<void> acceptCall({
bool isAudioEnabled = true,
bool isVideoEnabled = false,
bool isScreenSharingEnabled = false,
bool isAnimojiEnabled = false,
}) {
return sendCommand(
'accept-call',
extra: {
'mediaSettings': {
'isVideoEnabled': isVideoEnabled,
'isAudioEnabled': isAudioEnabled,
'isScreenSharingEnabled': isScreenSharingEnabled,
'isAnimojiEnabled': isAnimojiEnabled,
},
},
);
}
Future<void> hangup({String reason = 'HUNGUP'}) =>
sendCommand('hangup', extra: {'reason': reason});
Future<Map<String, dynamic>> allocateConsumer() => sendCommand(
'allocate-consumer',
extra: const {
'capabilities': {
'maxH264Decoders': 10,
'producerNotificationDataChannelVersion': 7,
'producerCommandDataChannelVersion': 2,
'audioMix': true,
'consumerUpdate': true,
'onDemandTracks': true,
'singleSession': true,
'unifiedPlan': true,
'fastScreenShare': true,
'consumerFastScreenShareQualityOnDemand': true,
'red': true,
'videoTracksCount': 10,
'csrcAccessible': true,
},
},
);
Future<Map<String, dynamic>> acceptProducer({
required String description,
required List<String> ssrcs,
Object? sessionId,
}) => sendCommand(
'accept-producer',
extra: {
'description': description,
if (ssrcs.isNotEmpty) 'ssrcs': ssrcs,
'sessionId': ?sessionId,
},
);
Future<void> close() async {
await _notifSub?.cancel();
_notifSub = null;
await _call?.close();
_call = null;
_onDone(null);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppAccent {
static const prefKey = 'app_accent_seed';
static const List<({String label, Color? seed})> presets = [
(label: 'Системный', seed: null),
(label: 'Сиреневый', seed: Color(0xFFC1C4FF)),
(label: 'Синий', seed: Color(0xFF4F8EFF)),
(label: 'Бирюзовый', seed: Color(0xFF00BFA5)),
(label: 'Зелёный', seed: Color(0xFF43A047)),
(label: 'Янтарный', seed: Color(0xFFFFB300)),
(label: 'Розовый', seed: Color(0xFFE91E63)),
(label: 'Красный', seed: Color(0xFFE53935)),
(label: 'Фиолетовый', seed: Color(0xFF7E57C2)),
];
static Future<Color?> load() async {
final prefs = await SharedPreferences.getInstance();
final val = prefs.getInt(prefKey);
if (val == null) return null;
return Color(val);
}
static Future<void> save(Color? color) async {
final prefs = await SharedPreferences.getInstance();
if (color == null) {
await prefs.remove(prefKey);
} else {
await prefs.setInt(prefKey, color.toARGB32());
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppAmoled {
static const prefKey = 'app_amoled';
static bool get systemIsDark =>
PlatformDispatcher.instance.platformBrightness == Brightness.dark;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: systemIsDark,
read: (prefs, key) => prefs.getBool(key) ?? systemIsDark,
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+12
View File
@@ -0,0 +1,12 @@
class AppAnimations {
const AppAnimations._();
static const String _dir = 'assets/lottie';
static const String clock = '$_dir/ic_clock.json';
static const String search = '$_dir/ic_search.json';
static const String settings = '$_dir/ic_settings.json';
static const String chat = '$_dir/ic_chat.json';
static const String call = '$_dir/ic_call.json';
static const String contacts = '$_dir/ic_contacts.json';
}
+20
View File
@@ -0,0 +1,20 @@
import 'persisted_setting.dart';
class AppBadge {
static final enabled = _flag('badge_enabled', true);
static final includeMuted = _flag('badge_include_muted', false);
static final countMessages = _flag('badge_count_messages', true);
static Future<void> load() =>
Future.wait([enabled.load(), includeMuted.load(), countMessages.load()]);
static PersistedSetting<bool> _flag(String key, bool fallback) =>
PersistedSetting<bool>(
prefKey: key,
defaultValue: fallback,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum BubbleBehavior { mutable, immutable }
class AppBubbleBehavior {
static const prefKey = 'app_bubble_behavior';
static final _setting = PersistedEnum<BubbleBehavior>(
prefKey: prefKey,
defaultValue: BubbleBehavior.mutable,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<BubbleBehavior> get current => _setting.current;
static Future<BubbleBehavior> load() => _setting.load();
static Future<void> save(BubbleBehavior behavior) => _setting.save(behavior);
static BubbleBehavior _parse(String? val) =>
enumFromName(BubbleBehavior.values, val, BubbleBehavior.mutable);
static String label(BubbleBehavior behavior) {
switch (behavior) {
case BubbleBehavior.mutable:
return 'Изменяемая';
case BubbleBehavior.immutable:
return 'Неизменяемая';
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum BubbleStyle { mobile, desktop }
class AppBubbleShape {
static const prefKey = 'app_bubble_shape';
static final _setting = PersistedEnum<BubbleStyle>(
prefKey: prefKey,
defaultValue: BubbleStyle.mobile,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<BubbleStyle> get current => _setting.current;
static Future<BubbleStyle> load() => _setting.load();
static Future<void> save(BubbleStyle style) => _setting.save(style);
static BubbleStyle _parse(String? val) =>
enumFromName(BubbleStyle.values, val, BubbleStyle.mobile);
static String label(BubbleStyle style) {
switch (style) {
case BubbleStyle.mobile:
return 'TG Mobile';
case BubbleStyle.desktop:
return 'TG Desktop';
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppCacheExtent {
static const prefKey = 'app_cache_extent';
static const double defaultValue = 5000;
static const double min = 1000;
static const double max = 10000;
static const double lowWarnThreshold = 2500;
static const double highWarnThreshold = 7000;
static final _setting = PersistedSetting<double>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getDouble(key),
write: (prefs, key, value) async {
await prefs.setDouble(key, value);
},
sanitize: clamp,
);
static ValueNotifier<double> get current => _setting.current;
static Future<double> load() => _setting.load();
static Future<void> save(double value) => _setting.save(value);
static double clamp(double v) {
if (v < min) return min;
if (v > max) return max;
return v;
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppCamera {
static const prefKey = 'media_camera_id';
static final _setting = PersistedSetting<String>(
prefKey: prefKey,
defaultValue: '',
read: (prefs, key) => prefs.getString(key),
write: (prefs, key, value) async {
await prefs.setString(key, value);
},
);
static ValueNotifier<String> get current => _setting.current;
static String? get deviceId =>
_setting.current.value.isEmpty ? null : _setting.current.value;
static Future<String> load() => _setting.load();
static Future<void> save(String value) => _setting.save(value);
}
class AppVideoNoteCamera {
static final _custom = PersistedSetting<bool>(
prefKey: 'video_note_custom_camera',
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get useCustom => _custom.current;
static String? get customCameraId =>
_custom.current.value ? AppCamera.deviceId : null;
static Future<bool> load() => _custom.load();
static Future<void> setUseCustom(bool value) => _custom.save(value);
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
import '../../frontend/widgets/liquid_glass.dart';
import 'persisted_setting.dart';
enum ChatChromeStyle { color, blur, none, transparent, liquidGlass }
class ChatChromeMaterial {
static bool isLiquid(ChatChromeStyle style) =>
style == ChatChromeStyle.liquidGlass && LiquidGlass.isSupported;
}
class AppChatChrome {
static const prefKey = 'app_chat_chrome';
static final _setting = PersistedEnum<ChatChromeStyle>(
prefKey: prefKey,
defaultValue: ChatChromeStyle.none,
encode: _encode,
decode: _parse,
);
static ValueNotifier<ChatChromeStyle> get current => _setting.current;
static ChatChromeStyle _parse(String? value) =>
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none);
static String _encode(ChatChromeStyle value) => value.name;
static Future<ChatChromeStyle> load() => _setting.load();
static Future<void> save(ChatChromeStyle value) => _setting.save(value);
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import '../utils/seed_scheme.dart';
extension AppColorTokens on ColorScheme {
Color get mutedText => onSurfaceVariant.withValues(alpha: 0.6);
}
const int kAvatarThumbSize = 144;
const Color kSuccessGreen = Color(0xFF2EC36B);
const Color kDangerRed = Color(0xFFE5484D);
const Color kReadReceiptBlue = Color(0xFF4FC3F7);
class MediaAccent {
static Color? _seed;
static ColorScheme? _scheme;
static ColorScheme schemeOf(BuildContext context) {
final seed = Theme.of(context).colorScheme.primary;
if (_seed != seed || _scheme == null) {
_seed = seed;
_scheme = schemeFromSeed(seed, Brightness.dark);
}
return _scheme!;
}
static Color of(BuildContext context) => schemeOf(context).primary;
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppCommands {
static const prefKey = 'dev_commands';
static const bool defaultValue = false;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import '../../frontend/widgets/liquid_glass.dart';
import 'persisted_setting.dart';
enum ComposerBackground { standard, frostBlur, liquidGlass }
class ComposerMaterial {
static bool isLiquid(ComposerBackground value) =>
value == ComposerBackground.liquidGlass && LiquidGlass.isSupported;
static bool isFrost(ComposerBackground value) =>
value == ComposerBackground.frostBlur ||
(value == ComposerBackground.liquidGlass && !LiquidGlass.isSupported);
}
class AppComposerBackground {
static const prefKey = 'app_composer_background';
static final _setting = PersistedEnum<ComposerBackground>(
prefKey: prefKey,
defaultValue: ComposerBackground.standard,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<ComposerBackground> get current => _setting.current;
static Future<ComposerBackground> load() => _setting.load();
static Future<void> save(ComposerBackground value) => _setting.save(value);
static ComposerBackground _parse(String? val) =>
enumFromName(ComposerBackground.values, val, ComposerBackground.standard);
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import 'app_visual_style.dart';
import 'persisted_setting.dart';
enum ComposerStyle { auto, glossy, materialYou }
class ComposerChrome {
static bool isGlossy(ComposerStyle style) => switch (style) {
ComposerStyle.auto => AppVisualStyle.current.value.glossyChrome,
ComposerStyle.glossy => true,
ComposerStyle.materialYou => false,
};
}
class AppComposerStyle {
static const prefKey = 'app_composer_style';
static final _setting = PersistedEnum<ComposerStyle>(
prefKey: prefKey,
defaultValue: ComposerStyle.glossy,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<ComposerStyle> get current => _setting.current;
static Future<ComposerStyle> load() => _setting.load();
static Future<void> save(ComposerStyle value) => _setting.save(value);
static ComposerStyle _parse(String? val) =>
enumFromName(ComposerStyle.values, val, ComposerStyle.glossy);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppDigitalIdNative {
static const prefKey = 'app_digital_id_native';
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'custom_font_service.dart';
const String kDisplayFontFamily = 'Outfit';
@immutable
class AppDisplayFont extends ThemeExtension<AppDisplayFont> {
final String? family;
const AppDisplayFont(this.family);
@override
AppDisplayFont copyWith({String? family}) =>
AppDisplayFont(family ?? this.family);
@override
AppDisplayFont lerp(ThemeExtension<AppDisplayFont>? other, double t) =>
t < 0.5 ? this : (other as AppDisplayFont? ?? this);
}
String? displayFontOf(BuildContext context) =>
Theme.of(context).extension<AppDisplayFont>()?.family ?? kDisplayFontFamily;
class AppFont {
final String id;
final String label;
final String? fontFamily;
final double metricScale;
const AppFont({
required this.id,
required this.label,
this.fontFamily,
this.metricScale = 1.0,
});
bool get isSystem => fontFamily == null;
bool get isCustom => id.startsWith(AppFonts.customPrefix);
}
class AppFonts {
static const String prefKey = 'app_font';
static const String scalePrefKey = 'app_font_scale';
static const String customPrefix = 'g:';
static const double minScale = 0.60;
static const double maxScale = 1.35;
static const double defaultScale = 1.0;
static const List<AppFont> builtIn = [
AppFont(id: 'system', label: 'Системный'),
AppFont(
id: 'inter',
label: 'Inter',
fontFamily: 'Inter',
metricScale: 0.967,
),
AppFont(
id: 'unbounded',
label: 'Unbounded',
fontFamily: 'Unbounded',
metricScale: 0.933,
),
];
static AppFont get fallback => builtIn.first;
static String customId(String family) => '$customPrefix$family';
static AppFont resolve(String id) {
if (id.startsWith(customPrefix)) {
final family = id.substring(customPrefix.length);
return AppFont(
id: id,
label: family,
fontFamily: family,
metricScale: CustomFontService.metricScaleFor(family),
);
}
return builtIn.firstWhere((f) => f.id == id, orElse: () => fallback);
}
static double effectiveScale(String id, double userScale) =>
userScale * resolve(id).metricScale;
static String? displayFamily(String id) {
final font = resolve(id);
return font.isSystem ? kDisplayFontFamily : font.fontFamily;
}
static TextTheme textTheme(String id, TextTheme base) {
final family = resolve(id).fontFamily;
if (family == null) return base;
return base.apply(fontFamily: family);
}
static TextStyle sample(String id, {required double fontSize}) {
final family = resolve(id).fontFamily;
return TextStyle(fontFamily: family, fontSize: fontSize);
}
static double clampScale(double scale) =>
scale.clamp(minScale, maxScale).toDouble();
static String? familyFromInput(String input) {
var value = input.trim();
if (value.isEmpty) return null;
final uri = Uri.tryParse(value);
if (uri != null && uri.host.contains('fonts.google.com')) {
final idx = uri.pathSegments.indexOf('specimen');
if (idx != -1 && idx + 1 < uri.pathSegments.length) {
value = uri.pathSegments[idx + 1];
}
}
try {
value = Uri.decodeComponent(value);
} catch (_) {}
value = value.replaceAll('+', ' ').trim();
return value.isEmpty ? null : value;
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class AppFrost {
static const double sigma = 34;
static const double panelSigma = 24;
static const double overlaySigma = 18;
static const double mediaBackdropSigma = 30;
static const double glassAlpha = 0.28;
static const double blurPanelAlpha = 0.55;
static const double scrimAlpha = 0.4;
static Color glassTint(ColorScheme cs, [double alpha = glassAlpha]) =>
cs.surfaceContainerHigh.withValues(alpha: alpha);
static Color blurPanelTint(ColorScheme cs) => glassTint(cs, blurPanelAlpha);
static Color scrim([double alpha = scrimAlpha]) =>
Colors.black.withValues(alpha: alpha);
static BorderSide hairline(ColorScheme cs) =>
BorderSide(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5);
}
+88
View File
@@ -0,0 +1,88 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum AppIcon {
defaultIcon(
'default',
'Default',
'assets/ikar_icon.png',
'MainActivity',
null,
),
minimal(
'minimal',
'Minimal',
'assets/meteor_icon.png',
'MinimalIcon',
'MinimalIcon',
);
final String id;
final String title;
final String previewAsset;
final String androidAlias;
final String? iosAlternateName;
const AppIcon(
this.id,
this.title,
this.previewAsset,
this.androidAlias,
this.iosAlternateName,
);
}
class AppIconConfig {
static const prefKey = 'app_icon';
static const _channel = MethodChannel('ru.ikar.app/app_icon');
static final ValueNotifier<AppIcon> current = ValueNotifier(
AppIcon.defaultIcon,
);
static bool get isSupported => Platform.isAndroid || Platform.isIOS;
static Future<void> load() async {
if (!isSupported) return;
final prefs = await SharedPreferences.getInstance();
var icon = _parse(prefs.getString(prefKey));
final applied = await _appliedIcon();
if (applied != null && applied != icon) {
icon = applied;
await prefs.setString(prefKey, icon.id);
}
current.value = icon;
}
static Future<void> apply(AppIcon icon) async {
if (!isSupported) return;
if (current.value == icon) return;
await _channel.invokeMethod<void>('setAppIcon', {
'name': Platform.isIOS ? icon.iosAlternateName : icon.androidAlias,
});
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, icon.id);
current.value = icon;
}
static Future<AppIcon?> _appliedIcon() async {
if (!Platform.isIOS) return null;
try {
final name = await _channel.invokeMethod<String>('getAppIcon');
for (final icon in AppIcon.values) {
if (icon.iosAlternateName == name) return icon;
}
} catch (_) {}
return null;
}
static AppIcon _parse(String? val) {
for (final icon in AppIcon.values) {
if (icon.id == val) return icon;
}
return AppIcon.defaultIcon;
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppLinkPreview {
static const prefKey = 'dev_link_preview';
static const bool defaultValue = true;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
class AppLiquidGlass {
static const bool enabled = false;
static const double blurSigma = 0;
static const double spread = 1;
static const double refraction = 34;
static const double chroma = 0;
static const double specular = 0.45;
static const double rimWidth = 8;
static const Offset light = Offset(-0.4, -1);
static const double tintFeather = 44;
static Color navTint(ColorScheme cs) =>
cs.surfaceContainerHigh.withValues(alpha: 0);
static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.24);
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppMediaCacheLimit {
static const prefKey = 'media_cache_limit_bytes';
static const int defaultValue = 500 * 1024 * 1024; // 500 МБ
/// Значение «без лимита» — вытеснение из кэша отключено.
static const int unlimited = 0;
/// Доступные пресеты лимита, байты (0 — без лимита).
static const List<int> presets = [
100 * 1024 * 1024,
250 * 1024 * 1024,
500 * 1024 * 1024,
1024 * 1024 * 1024,
2 * 1024 * 1024 * 1024,
unlimited,
];
static final _setting = PersistedSetting<int>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getInt(key),
write: (prefs, key, value) async {
await prefs.setInt(key, value);
},
);
static ValueNotifier<int> get current => _setting.current;
static Future<int> load() => _setting.load();
static Future<void> save(int value) => _setting.save(value);
}
@@ -0,0 +1,42 @@
import 'package:flutter/foundation.dart';
import 'ios_release.dart';
import 'persisted_setting.dart';
enum MessageActionsStyle { radial, list }
class AppMessageActionsStyle {
static const prefKey = 'app_message_actions_style';
static final _setting = PersistedEnum<MessageActionsStyle>(
prefKey: prefKey,
defaultValue: MessageActionsStyle.list,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<MessageActionsStyle> get current => _setting.current;
/// Style the menu opens with. iOS ignores a stored radial choice and
/// always uses the list.
static MessageActionsStyle get effective =>
IosRelease.messageActionsStyleChoice
? current.value
: MessageActionsStyle.list;
static Future<MessageActionsStyle> load() => _setting.load();
static Future<void> save(MessageActionsStyle style) => _setting.save(style);
static MessageActionsStyle _parse(String? val) =>
enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.list);
static String label(MessageActionsStyle style) {
switch (style) {
case MessageActionsStyle.radial:
return 'Радиальное';
case MessageActionsStyle.list:
return 'Список';
}
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppMessageStatusSize {
static const prefKey = 'app_message_status_scale';
static const double defaultScale = 1;
static const double minScale = 1;
static const double maxScale = 1.5;
static final _setting = PersistedSetting<double>(
prefKey: prefKey,
defaultValue: defaultScale,
read: (prefs, key) => prefs.getDouble(key),
write: (prefs, key, value) async {
await prefs.setDouble(key, value);
},
sanitize: clampScale,
);
static ValueNotifier<double> get current => _setting.current;
static double clampScale(double value) => value.isFinite
? value.clamp(minScale, maxScale).toDouble()
: defaultScale;
static Future<double> load() => _setting.load();
static Future<void> save(double value) => _setting.save(value);
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppMicrophone {
static const prefKey = 'call_microphone_id';
static const String defaultValue = '';
static final _setting = PersistedSetting<String>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getString(key),
write: (prefs, key, value) async {
await prefs.setString(key, value);
},
);
static ValueNotifier<String> get current => _setting.current;
static String? get deviceId =>
_setting.current.value.isEmpty ? null : _setting.current.value;
static Future<String> load() => _setting.load();
static Future<void> save(String value) => _setting.save(value);
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import '../../frontend/widgets/liquid_glass.dart';
import 'app_visual_style.dart';
import 'persisted_setting.dart';
enum NavPillStyle { auto, glossy, frostBlur, liquidGlass }
class NavPillMaterial {
static NavPillStyle resolve(NavPillStyle style) {
if (style != NavPillStyle.auto) return style;
return AppVisualStyle.current.value == VisualStyle.liquidGlass
? NavPillStyle.liquidGlass
: NavPillStyle.glossy;
}
static bool isLiquid(NavPillStyle style) =>
resolve(style) == NavPillStyle.liquidGlass && LiquidGlass.isSupported;
static bool isFrost(NavPillStyle style) {
final resolved = resolve(style);
return resolved == NavPillStyle.frostBlur ||
(resolved == NavPillStyle.liquidGlass && !LiquidGlass.isSupported);
}
}
class AppNavPillStyle {
static const prefKey = 'app_nav_pill_style';
static final _setting = PersistedEnum<NavPillStyle>(
prefKey: prefKey,
defaultValue: NavPillStyle.glossy,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<NavPillStyle> get current => _setting.current;
static Future<NavPillStyle> load() => _setting.load();
static Future<void> save(NavPillStyle value) => _setting.save(value);
static NavPillStyle _parse(String? val) =>
enumFromName(NavPillStyle.values, val, NavPillStyle.glossy);
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppPhonebookNames {
static const prefKey = 'dev_phonebook_names';
static const bool defaultValue = true;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppPillGradient {
static const prefKey = 'app_pill_gradient';
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppPranks {
static const prefKey = 'dev_pranks';
static const bool defaultValue = false;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppPulseSource {
static const prefKey = 'call_pulse_source';
static const String defaultValue = '';
static final _setting = PersistedSetting<String>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getString(key),
write: (prefs, key, value) async {
await prefs.setString(key, value);
},
);
static ValueNotifier<String> get current => _setting.current;
static String? get name =>
_setting.current.value.isEmpty ? null : _setting.current.value;
static Future<String> load() => _setting.load();
static Future<void> save(String value) => _setting.save(value);
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class AppShape {
static const double card = 20;
static const double button = 14;
static const double sheet = 24;
static const double dialog = 24;
static const double pill = 100;
static const BorderRadius cardRadius = BorderRadius.all(
Radius.circular(card),
);
static const BorderRadius buttonRadius = BorderRadius.all(
Radius.circular(button),
);
static const BorderRadius pillRadius = BorderRadius.all(
Radius.circular(pill),
);
static const RoundedRectangleBorder buttonBorder = RoundedRectangleBorder(
borderRadius: buttonRadius,
);
static const RoundedRectangleBorder dialogBorder = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(dialog)),
);
static const RoundedRectangleBorder sheetBorder = RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(sheet)),
);
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppShowExtraInfo {
static const prefKey = 'dev_show_extra_info';
static const bool defaultValue = false;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppSpectrumBackground {
static const prefKey = 'app_spectrum_background';
static const bool defaultValue = false;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static bool get isEnabled => _setting.current.value;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppStories {
static const prefKey = 'dev_stories';
static const bool defaultValue = true;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppSwipeBackDesktop {
static const prefKey = 'dev_swipe_back_desktop';
static const bool defaultValue = false;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum AppThemeMode { system, light, dark, schedule }
class AppThemeModeConfig {
static const prefKey = 'app_theme_mode';
static final _setting = PersistedEnum<AppThemeMode>(
prefKey: prefKey,
defaultValue: AppThemeMode.system,
encode: (mode) => mode.name,
decode: _parse,
);
static ValueNotifier<AppThemeMode> get current => _setting.current;
static Future<AppThemeMode> load() => _setting.load();
static Future<void> save(AppThemeMode mode) => _setting.save(mode);
static AppThemeMode _parse(String? val) =>
enumFromName(AppThemeMode.values, val, AppThemeMode.system);
static String label(AppThemeMode mode) {
switch (mode) {
case AppThemeMode.system:
return 'Системная';
case AppThemeMode.light:
return 'Светлая';
case AppThemeMode.dark:
return 'Тёмная';
case AppThemeMode.schedule:
return 'По расписанию';
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/format.dart';
class ThemeSchedule {
final TimeOfDay darkStart;
final TimeOfDay darkEnd;
const ThemeSchedule({required this.darkStart, required this.darkEnd});
bool isDarkAt(DateTime now) {
final nowMin = now.hour * 60 + now.minute;
final startMin = darkStart.hour * 60 + darkStart.minute;
final endMin = darkEnd.hour * 60 + darkEnd.minute;
if (startMin == endMin) return false;
if (startMin < endMin) {
return nowMin >= startMin && nowMin < endMin;
}
return nowMin >= startMin || nowMin < endMin;
}
Duration durationUntilNextSwitch(DateTime now) {
final nowMin = now.hour * 60 + now.minute;
final startMin = darkStart.hour * 60 + darkStart.minute;
final endMin = darkEnd.hour * 60 + darkEnd.minute;
int? nextMin;
for (final m in [startMin, endMin]) {
final delta = (m - nowMin + 1440) % 1440;
final candidate = delta == 0 ? 1440 : delta;
if (nextMin == null || candidate < nextMin) nextMin = candidate;
}
final secondsLeft = (nextMin ?? 1) * 60 - now.second;
return Duration(seconds: secondsLeft.clamp(1, 24 * 60 * 60));
}
}
class AppThemeSchedule {
static const prefKey = 'app_theme_schedule';
static const _defaultStart = TimeOfDay(hour: 22, minute: 0);
static const _defaultEnd = TimeOfDay(hour: 7, minute: 0);
static final ValueNotifier<ThemeSchedule> current = ValueNotifier(
const ThemeSchedule(darkStart: _defaultStart, darkEnd: _defaultEnd),
);
static Future<ThemeSchedule> load() async {
final prefs = await SharedPreferences.getInstance();
final value = _parse(prefs.getString(prefKey));
current.value = value;
return value;
}
static Future<void> save(ThemeSchedule schedule) async {
current.value = schedule;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
prefKey,
'${_fmt(schedule.darkStart)}-${_fmt(schedule.darkEnd)}',
);
}
static String _fmt(TimeOfDay t) => '${pad2(t.hour)}:${pad2(t.minute)}';
static ThemeSchedule _parse(String? val) {
if (val == null) {
return const ThemeSchedule(
darkStart: _defaultStart,
darkEnd: _defaultEnd,
);
}
final parts = val.split('-');
if (parts.length != 2) {
return const ThemeSchedule(
darkStart: _defaultStart,
darkEnd: _defaultEnd,
);
}
final start = _parseTime(parts[0]) ?? _defaultStart;
final end = _parseTime(parts[1]) ?? _defaultEnd;
return ThemeSchedule(darkStart: start, darkEnd: end);
}
static TimeOfDay? _parseTime(String val) {
final parts = val.split(':');
if (parts.length != 2) return null;
final h = int.tryParse(parts[0]);
final m = int.tryParse(parts[1]);
if (h == null || m == null) return null;
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return TimeOfDay(hour: h, minute: m);
}
static String format(TimeOfDay t) => _fmt(t);
}
@@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppVideoNoteResolution {
static const prefKey = 'dev_video_note_resolution';
static const int defaultValue = 480;
static const List<int> presets = [480, 720, 1080];
static final _setting = PersistedSetting<int>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getInt(key),
write: (prefs, key, value) async {
await prefs.setInt(key, value);
},
sanitize: (value) => presets.contains(value) ? value : defaultValue,
);
static ValueNotifier<int> get current => _setting.current;
static Future<int> load() => _setting.load();
static Future<void> save(int value) => _setting.save(value);
}
class AppVideoNoteRearCamera {
static const prefKey = 'dev_video_note_rear_camera';
static const bool defaultValue = false;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
class AppVideoNoteFps {
static const prefKey = 'dev_video_note_fps';
static const int defaultValue = 30;
static const List<int> presets = [30, 60];
static final _setting = PersistedSetting<int>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getInt(key),
write: (prefs, key, value) async {
await prefs.setInt(key, value);
},
sanitize: (value) => presets.contains(value) ? value : defaultValue,
);
static ValueNotifier<int> get current => _setting.current;
static Future<int> load() => _setting.load();
static Future<void> save(int value) => _setting.save(value);
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum VisualStyle { materialYou, glossy, liquidGlass }
extension VisualStyleChrome on VisualStyle {
bool get glossyChrome => this != VisualStyle.materialYou;
}
class AppVisualStyle {
static const prefKey = 'app_visual_style';
static final _setting = PersistedEnum<VisualStyle>(
prefKey: prefKey,
defaultValue: VisualStyle.materialYou,
encode: _encode,
decode: _parse,
);
static ValueNotifier<VisualStyle> get current => _setting.current;
static Future<VisualStyle> load() => _setting.load();
static Future<void> save(VisualStyle value) => _setting.save(value);
static String _encode(VisualStyle value) => value.name;
static VisualStyle _parse(String? val) =>
enumFromName(VisualStyle.values, val, VisualStyle.materialYou);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppWallpaperTint {
static const prefKey = 'app_wallpaper_tint';
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/services.dart' show appFlavor;
// #***! что включено в сборке, всё считается на компиляции из flavor
abstract final class BuildProfile {
static const String storeFlavor = 'store';
// #***! store сборка урезана, без самообновления дев инструментов и спуфа
static const bool isStore = appFlavor == storeFlavor;
static const bool plugins = true;
static const bool selfUpdate = !isStore;
static const bool firebasePush = appFlavor == 'ikar';
static const bool spoofUi = !isStore;
static const bool tokenLogin = !isStore;
static const bool devTools = !isStore;
static const bool insecureTransport = !isStore;
static const bool trafficCapture = !isStore;
static const bool pranks = !isStore;
static const bool hiddenContentViewers = !isStore;
static const bool digitalId = !isStore;
static const bool ipGeoLookup = !isStore;
static const bool sessionCityLookup = !isStore;
}
+11
View File
@@ -0,0 +1,11 @@
class CallNoMute {
CallNoMute._();
static const String flag = '--no-mute';
static bool enabled = const bool.fromEnvironment('NO_MUTE');
static void parse(List<String> args) {
if (args.contains(flag)) enabled = true;
}
}
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import '../utils/tiled_svg.dart';
@immutable
class ChatWallpaperTheme {
final String id;
final List<Color> colors;
final AlignmentGeometry begin;
final AlignmentGeometry end;
final String? pattern;
final Color patternColor;
final double patternOpacity;
final double tileSize;
final bool dark;
const ChatWallpaperTheme({
required this.id,
required this.colors,
this.begin = Alignment.topLeft,
this.end = Alignment.bottomRight,
this.pattern,
this.patternColor = Colors.white,
this.patternOpacity = 0.1,
this.tileSize = 130,
this.dark = true,
});
Gradient get gradient =>
LinearGradient(colors: colors, begin: begin, end: end);
Color get bubbleTint => colors.first;
Widget buildBackground() => _ChatWallpaperThemeView(theme: this);
Widget buildPreview() =>
_ChatWallpaperThemeView(theme: this, tileScale: 0.42);
}
class _ChatWallpaperThemeView extends StatelessWidget {
final ChatWallpaperTheme theme;
final double tileScale;
const _ChatWallpaperThemeView({required this.theme, this.tileScale = 1});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
DecoratedBox(decoration: BoxDecoration(gradient: theme.gradient)),
if (theme.pattern != null)
TiledSvgPattern(
asset: theme.pattern!,
color: theme.patternColor,
opacity: theme.patternOpacity,
tileSize: theme.tileSize * tileScale,
),
],
);
}
}
const String _kPatternDir = 'assets/wallpapers/patterns';
const List<ChatWallpaperTheme> kChatWallpaperThemes = <ChatWallpaperTheme>[
ChatWallpaperTheme(
id: 'ocean',
colors: [Color(0xFF2A7B9B), Color(0xFF57C1EB), Color(0xFF246FA8)],
pattern: '$_kPatternDir/bubbles.svg',
patternOpacity: 0.1,
),
ChatWallpaperTheme(
id: 'sunset',
colors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)],
pattern: '$_kPatternDir/hearts.svg',
patternOpacity: 0.12,
),
ChatWallpaperTheme(
id: 'lavender',
colors: [Color(0xFF9D50BB), Color(0xFF6E48AA)],
pattern: '$_kPatternDir/stars.svg',
patternOpacity: 0.11,
),
ChatWallpaperTheme(
id: 'mint',
colors: [Color(0xFF43E97B), Color(0xFF38F9D7)],
pattern: '$_kPatternDir/plus.svg',
patternColor: Colors.black,
patternOpacity: 0.06,
tileSize: 66,
dark: false,
),
ChatWallpaperTheme(
id: 'graphite',
colors: [Color(0xFF232526), Color(0xFF414345)],
pattern: '$_kPatternDir/plus.svg',
patternOpacity: 0.06,
tileSize: 66,
),
ChatWallpaperTheme(
id: 'sky',
colors: [Color(0xFF2193B0), Color(0xFF6DD5ED)],
pattern: '$_kPatternDir/planes.svg',
patternOpacity: 0.11,
),
ChatWallpaperTheme(
id: 'peach',
colors: [Color(0xFFFFD3A5), Color(0xFFFD6585)],
pattern: '$_kPatternDir/rings.svg',
patternColor: Colors.black,
patternOpacity: 0.05,
dark: false,
),
ChatWallpaperTheme(
id: 'forest',
colors: [Color(0xFF134E5E), Color(0xFF71B280)],
pattern: '$_kPatternDir/rings.svg',
patternOpacity: 0.09,
),
ChatWallpaperTheme(
id: 'grape',
colors: [Color(0xFF4776E6), Color(0xFF8E54E9)],
pattern: '$_kPatternDir/stars.svg',
patternOpacity: 0.11,
),
ChatWallpaperTheme(
id: 'night',
colors: [Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364)],
pattern: '$_kPatternDir/stars.svg',
patternOpacity: 0.08,
),
ChatWallpaperTheme(
id: 'rose',
colors: [Color(0xFFF4C4F3), Color(0xFFFC67FA)],
pattern: '$_kPatternDir/hearts.svg',
patternOpacity: 0.14,
),
ChatWallpaperTheme(
id: 'amber',
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
pattern: '$_kPatternDir/bubbles.svg',
patternColor: Colors.black,
patternOpacity: 0.05,
dark: false,
),
];
ChatWallpaperTheme? chatWallpaperThemeById(String? id) {
if (id == null) return null;
for (final theme in kChatWallpaperThemes) {
if (theme.id == id) return theme;
}
return null;
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:shared_preferences/shared_preferences.dart';
// #***! адрес сервера и тайминги
abstract class ServerConfig {
static const String defaultHost = 'ikar.kusoft.xyz';
static const List<String> presetHosts = [defaultHost];
static const int defaultPort = 443;
static const bool defaultTrustMincifryCa = false;
static const String prefHostKey = 'server_host_override';
static const String prefPortKey = 'server_port_override';
static const String prefTrustMincifryKey = 'server_trust_mincifry_ca';
static const Duration pingInterval = Duration(seconds: 10);
static const Duration requestTimeout = Duration(seconds: 30);
static const int maxReconnectAttempts = 50;
// #***! в dev адрес можно переопределить, в релизе всегда дефолтный
static Future<({String host, int port, bool trustMincifryCa})>
loadEndpoint() async {
final prefs = await SharedPreferences.getInstance();
final rawHost = prefs.getString(prefHostKey);
final rawPort = prefs.getInt(prefPortKey);
final host = (rawHost != null && rawHost.trim().isNotEmpty)
? rawHost.trim()
: defaultHost;
var port = defaultPort;
if (isValidPort(rawPort)) port = rawPort!;
return (
host: host,
port: port,
trustMincifryCa: false,
);
}
static bool isValidPort(int? port) =>
port != null && port >= 1 && port <= 65535;
static Future<void> saveEndpoint({
required String host,
required int port,
required bool trustMincifryCa,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefHostKey, host);
await prefs.setInt(prefPortKey, port);
await prefs.setBool(prefTrustMincifryKey, trustMincifryCa);
}
static Future<void> resetEndpoint() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(prefHostKey);
await prefs.remove(prefPortKey);
await prefs.remove(prefTrustMincifryKey);
}
}
+512
View File
@@ -0,0 +1,512 @@
import 'dart:convert';
class CountryName {
final String code; // ISO 3166-1 alpha-2, например "RU"
final String en;
final String ru;
final String phoneCode; // Код страны для звонков, например "+7"
final int phoneDigits; // Количество цифр номера после кода страны
final String
phoneMask; // Маска абонентского номера, например "(###) ###-##-##"
final List<int> phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2]
final List<String>
phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""]
const CountryName({
required this.code,
required this.en,
required this.ru,
required this.phoneCode,
required this.phoneDigits,
required this.phoneMask,
required this.phoneGroupSizes,
required this.phoneGroupSeparators,
});
String displayName(String languageCode) {
return languageCode == 'ru' ? ru : en;
}
}
/// Полный список стран (195 государств) с названием на русском и английском.
///
/// Источник данных:
/// https://github.com/stefangabos/world_countries
final List<CountryName> allCountries = _buildCountries();
/// Быстрый доступ по ISO-коду.
final Map<String, CountryName> countriesByCode = {
for (final country in allCountries) country.code: country,
};
const Map<String, String> primaryCountryByPhoneCode = {'+7': 'RU', '+1': 'US'};
bool isPrimaryForPhoneCode(CountryName country) =>
primaryCountryByPhoneCode[country.phoneCode] == country.code;
List<CountryName> sortedByDisplayName(
Iterable<CountryName> countries,
String languageCode,
) {
final list = countries.toList();
list.sort(
(a, b) => a
.displayName(languageCode)
.toLowerCase()
.compareTo(b.displayName(languageCode).toLowerCase()),
);
return list;
}
List<CountryName> countriesInServerOrder(Iterable<String> codes) {
final out = <CountryName>[];
for (final raw in codes) {
final c = countriesByCode[raw.toUpperCase()];
if (c != null) out.add(c);
}
return out;
}
/// Пример использования:
/// ```dart
/// final country = exampleCountryLookup('RU');
/// print(country?['ru']); // Россия
/// print(country?['en']); // Russian Federation
/// ```
Map<String, String>? exampleCountryLookup(String code) {
final country = countriesByCode[code.toUpperCase()];
if (country == null) return null;
return {'code': country.code, 'ru': country.ru, 'en': country.en};
}
List<CountryName> _buildCountries() {
final enList = (jsonDecode(_countriesEnJson) as List)
.cast<Map<String, dynamic>>();
final ruList = (jsonDecode(_countriesRuJson) as List)
.cast<Map<String, dynamic>>();
final ruByCode = <String, String>{
for (final item in ruList)
(item['alpha2'] as String).toUpperCase(): item['name'] as String,
};
final countries = enList.map((item) {
final code = (item['alpha2'] as String).toUpperCase();
final en = item['name'] as String;
final ru = ruByCode[code] ?? en;
final phoneCode = item['phoneCode'] as String;
final phoneDigits = item['phoneDigits'] as int;
final phoneMask = item['phoneMask'] as String;
final phoneGroupSizes = (item['phoneGroupSizes'] as List).cast<int>();
final phoneGroupSeparators = (item['phoneGroupSeparators'] as List)
.cast<String>();
return CountryName(
code: code,
en: en,
ru: ru,
phoneCode: phoneCode,
phoneDigits: phoneDigits,
phoneMask: phoneMask,
phoneGroupSizes: phoneGroupSizes,
phoneGroupSeparators: phoneGroupSeparators,
);
}).toList();
countries.sort((a, b) => a.en.compareTo(b.en));
return countries;
}
const String _countriesEnJson = '''[
{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Afghanistan", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Albania", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Algeria", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Andorra", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]},
{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Angola", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Antigua and Barbuda", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Argentina", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Armenia", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Australia", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Austria", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Azerbaijan", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Bahamas", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Bahrain", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Bangladesh", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]},
{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Barbados", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Belarus", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]},
{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Belgium", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Belize", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Benin", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Bhutan", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Bolivia, Plurinational State of", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]},
{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Bosnia and Herzegovina", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]},
{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Botswana", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Brazil", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Brunei Darussalam", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Bulgaria", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Burkina Faso", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Burundi", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Cabo Verde", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Cambodia", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Cameroon", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Canada", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "Central African Republic", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Chad", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Chile", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "China", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Colombia", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Comoros", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Congo", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "Congo, Democratic Republic of the", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Costa Rica", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Côte d'Ivoire", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Croatia", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Cuba", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]},
{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Cyprus", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Czechia", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Denmark", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Djibouti", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Dominica", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Dominican Republic", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Ecuador", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Egypt", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "El Salvador", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Equatorial Guinea", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Eritrea", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Estonia", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Eswatini", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Ethiopia", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Fiji", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Finland", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "France", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Gabon", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Gambia", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Georgia", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Germany", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]},
{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Ghana", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Greece", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Grenada", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Guatemala", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Guinea", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Guinea-Bissau", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Guyana", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Haiti", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Honduras", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]},
{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Israel", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]},
{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Italy", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Jamaica", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Japan", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]},
{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Jordan", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Kazakhstan", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]},
{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Kenya", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Kiribati", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "Korea, Democratic People's Republic of", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Korea, Republic of", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]},
{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Kuwait", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Kyrgyzstan", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Lao People's Democratic Republic", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Latvia", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Lebanon", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Lesotho", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Liberia", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Libya", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Liechtenstein", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Lithuania", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]},
{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Luxembourg", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Madagascar", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Malawi", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Malaysia", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Maldives", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Mali", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Malta", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Marshall Islands", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Mauritania", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Mauritius", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Mexico", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Micronesia, Federated States of", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Moldova, Republic of", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Monaco", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Mongolia", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Montenegro", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Morocco", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]},
{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Mozambique", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Myanmar", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Namibia", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Nauru", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Nepal", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Netherlands", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "New Zealand", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Nicaragua", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Niger", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Nigeria", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "North Macedonia", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Norway", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Oman", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Pakistan", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]},
{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Palau", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Panama", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Papua New Guinea", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Paraguay", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Peru", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Philippines", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Poland", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Portugal", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Qatar", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Romania", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Russian Federation", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]},
{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Rwanda", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Saint Kitts and Nevis", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Saint Lucia", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Saint Vincent and the Grenadines", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Samoa", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]},
{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "San Marino", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Sao Tome and Principe", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Saudi Arabia", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Senegal", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Serbia", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Seychelles", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Sierra Leone", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Singapore", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Slovakia", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Slovenia", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Solomon Islands", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Somalia", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "South Africa", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "South Sudan", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Spain", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Sri Lanka", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Sudan", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Suriname", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Sweden", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]},
{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Switzerland", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Syrian Arab Republic", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Tajikistan", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Tanzania, United Republic of", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Thailand", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Timor-Leste", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Togo", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Tonga", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Trinidad and Tobago", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Tunisia", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Türkiye", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Turkmenistan", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Tuvalu", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]},
{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Uganda", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Ukraine", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "United Arab Emirates", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "United Kingdom of Great Britain and Northern Ireland", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "United States of America", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Uruguay", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Uzbekistan", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Vanuatu", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Venezuela, Bolivarian Republic of", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Viet Nam", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Yemen", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Zambia", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Zimbabwe", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}
]''';
const String _countriesRuJson = '''[
{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Австралия", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Австрия", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Азербайджан", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Албания", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Алжир", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Ангола", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Андорра", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]},
{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Антигуа и Барбуда", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Аргентина", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Армения", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Афганистан", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Багамские Острова", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Бангладеш", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]},
{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Барбадос", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Бахрейн", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Белиз", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Беларусь", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]},
{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Бельгия", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Бенин", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Болгария", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Боливия", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]},
{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Босния и Герцеговина", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]},
{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Ботсвана", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Бразилия", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Бруней", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Буркина-Фасо", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Бурунди", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Бутан", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Вануату", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "Великобритания", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Венгрия", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Венесуэла", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Восточный Тимор", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Вьетнам", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Габон", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Гаити", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Гайана", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Гамбия", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Гана", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Гватемала", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Гвинея", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Гвинея-Бисау", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Германия", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]},
{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Гондурас", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Гренада", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Греция", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Грузия", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Дания", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Джибути", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Доминика", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Доминиканская Республика", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "ДР Конго", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Египет", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Замбия", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]},
{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]},
{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ирландия", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Исландия", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Испания", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Италия", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Йемен", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Кабо-Верде", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Казахстан", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]},
{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Камбоджа", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Камерун", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Канада", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Катар", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Кения", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Кипр", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Кыргызстан", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Кирибати", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "КНДР (Корейская Народно-Демократическая Республика)", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "Китай (Китайская Народная Республика)", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Колумбия", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Коморы", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Коста-Рика", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Кот-д’Ивуар", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Куба", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]},
{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Кувейт", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Лаос", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Латвия", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Лесото", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Либерия", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Ливан", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Ливия", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Литва", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]},
{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Лихтенштейн", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Люксембург", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Маврикий", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Мавритания", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Мадагаскар", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "Северная Македония", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Малави", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Малайзия", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Мали", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Мальдивы", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Мальта", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Марокко", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]},
{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Маршалловы Острова", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Мексика", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Микронезия", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Мозамбик", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Молдова", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Монако", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Монголия", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Мьянма", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Намибия", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Науру", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Непал", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Нигер", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Нигерия", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Нидерланды", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Никарагуа", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "Новая Зеландия", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Норвегия", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "ОАЭ", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Оман", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Пакистан", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]},
{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Палау", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Панама", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Папуа — Новая Гвинея", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Парагвай", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Перу", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Польша", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Португалия", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Республика Конго", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Республика Корея", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]},
{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Россия", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]},
{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Руанда", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Румыния", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "Сальвадор", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Самоа", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]},
{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "Сан-Марино", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Сан-Томе и Принсипи", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Саудовская Аравия", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Сейшельские Острова", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Сенегал", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Сент-Винсент и Гренадины", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Сент-Китс и Невис", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Сент-Люсия", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Сербия", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Сингапур", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Сирия", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Словакия", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Словения", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Соломоновы Острова", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Сомали", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Судан", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Суринам", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]},
{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "США", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Сьерра-Леоне", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]},
{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Таджикистан", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Таиланд", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]},
{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Танзания", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Того", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Тонга", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Тринидад и Тобаго", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Тувалу", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]},
{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Тунис", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Туркменистан", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Турция", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Уганда", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Узбекистан", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Украина", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Уругвай", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Фиджи", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Филиппины", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Финляндия", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "Франция", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]},
{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Хорватия", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "ЦАР", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Чад", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Черногория", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Чехия", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Чили", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Швейцария", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Швеция", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]},
{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Шри-Ланка", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Эквадор", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Экваториальная Гвинея", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Эритрея", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Эсватини", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]},
{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Эстония", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]},
{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Эфиопия", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "ЮАР", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "Южный Судан", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]},
{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Ямайка", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]},
{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Япония", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}
]''';
+191
View File
@@ -0,0 +1,191 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/logger.dart';
import 'font_metrics.dart';
import 'font_file_info.dart';
class CustomFontService {
static const String prefKey = 'app_custom_fonts';
static const String _userAgent =
'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) '
'AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30';
static final Set<String> _loaded = <String>{};
static final Map<String, double> _metricScales = <String, double>{};
static double metricScaleFor(String family) => _metricScales[family] ?? 1.0;
static Future<List<String>> families() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(prefKey) ?? const <String>[];
}
static Future<void> preloadCached() async {
final dir = await _cacheDir();
for (final family in await families()) {
if (_loaded.contains(family)) continue;
final file = _fileFor(dir, family);
if (!await file.exists()) continue;
try {
await _register(family, await file.readAsBytes());
} catch (_) {}
}
}
static Future<String?> addFamily(String family) async {
try {
final dir = await _cacheDir();
final file = _fileFor(dir, family);
Uint8List? bytes;
if (await file.exists()) {
final cached = await file.readAsBytes();
if (_isSfnt(cached)) bytes = cached;
}
if (bytes == null) {
bytes = await _download(family);
if (bytes == null) return null;
await file.writeAsBytes(bytes);
}
await _register(family, bytes);
await _persist(family);
return family;
} catch (e) {
logger.w('CustomFont: не удалось добавить «$family»: $e');
return null;
}
}
static Future<void> removeFamily(String family) async {
final prefs = await SharedPreferences.getInstance();
final list = prefs.getStringList(prefKey) ?? <String>[];
list.remove(family);
await prefs.setStringList(prefKey, list);
final file = _fileFor(await _cacheDir(), family);
if (await file.exists()) await file.delete();
}
static Future<String> addFromFile(Uint8List bytes, String fileName) async {
if (!_isSfnt(bytes)) {
throw const FormatException('not a ttf/otf/ttc font');
}
final family = FontFileInfo.familyName(bytes) ??
FontFileInfo.familyFromFileName(fileName);
final file = _fileFor(await _cacheDir(), family);
await _register(family, bytes);
await file.writeAsBytes(bytes, flush: true);
await _persist(family);
return family;
}
static Future<Directory> _cacheDir() async {
final base = await getApplicationSupportDirectory();
final dir = Directory('${base.path}/custom_fonts');
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
static File _fileFor(Directory dir, String family) {
final safe = family.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
final ascii = RegExp(r'^[A-Za-z0-9 _-]*$').hasMatch(family);
var hash = 0x811c9dc5;
for (final unit in family.codeUnits) {
hash = ((hash ^ unit) * 0x01000193) & 0xffffffff;
}
final name = ascii ? safe : '${safe}_${hash.toRadixString(16)}';
return File('${dir.path}/$name.ttf');
}
static Future<void> _register(String family, Uint8List bytes) async {
if (!_isSfnt(bytes)) {
throw const FormatException('downloaded data is not a ttf/otf font');
}
final loader = FontLoader(family)
..addFont(Future<ByteData>.value(ByteData.sublistView(bytes)));
await loader.load();
_loaded.add(family);
final xHeight = FontMetrics.xHeightRatio(bytes);
if (xHeight != null) {
_metricScales[family] = FontMetrics.scaleForXHeight(xHeight);
}
}
static bool _isSfnt(Uint8List b) {
if (b.length < 4) return false;
final tag = (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3];
return tag == 0x00010000 ||
tag == 0x4F54544F ||
tag == 0x74727565 ||
tag == 0x74746366;
}
static Future<void> _persist(String family) async {
final prefs = await SharedPreferences.getInstance();
final list = prefs.getStringList(prefKey) ?? <String>[];
if (!list.contains(family)) {
list.add(family);
await prefs.setStringList(prefKey, list);
}
}
static Future<Uint8List?> _download(String family) async {
final encoded = Uri.encodeQueryComponent(family);
final variants = <String>[
'https://fonts.googleapis.com/css2?family=$encoded',
'https://fonts.googleapis.com/css2?family=$encoded:wght@400',
'https://fonts.googleapis.com/css2?family=$encoded:wght@100..900',
];
final urlRegex = RegExp(r'url\((https://[^)]+)\)');
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 15);
try {
for (final url in variants) {
final css = await _fetchText(client, Uri.parse(url));
if (css == null) continue;
for (final match in urlRegex.allMatches(css)) {
final fontUrl = match.group(1);
if (fontUrl == null) continue;
final bytes = await _fetchBytes(client, Uri.parse(fontUrl));
if (bytes != null && _isSfnt(bytes)) return bytes;
}
}
return null;
} catch (_) {
return null;
} finally {
client.close(force: true);
}
}
static Future<String?> _fetchText(HttpClient client, Uri uri) async {
final req = await client.getUrl(uri);
req.headers.set(HttpHeaders.userAgentHeader, _userAgent);
final resp = await req.close().timeout(const Duration(seconds: 20));
if (resp.statusCode != HttpStatus.ok) {
await resp.drain<void>();
return null;
}
return resp
.transform(const Utf8Decoder())
.join()
.timeout(const Duration(seconds: 20));
}
static Future<Uint8List?> _fetchBytes(HttpClient client, Uri uri) async {
final req = await client.getUrl(uri);
req.headers.set(HttpHeaders.userAgentHeader, _userAgent);
final resp = await req.close().timeout(const Duration(seconds: 20));
if (resp.statusCode != HttpStatus.ok) {
await resp.drain<void>();
return null;
}
final builder = BytesBuilder(copy: false);
await resp.forEach(builder.add).timeout(const Duration(seconds: 30));
return builder.takeBytes();
}
}
+45
View File
@@ -0,0 +1,45 @@
class DebugTest {
static bool enabled = false;
static int contactCount = 0;
static bool berserk = false;
static const int debugAccountId = -424242;
static const bool _envEnabled = bool.fromEnvironment('DEBUG_TEST');
static const bool _envBerserk = bool.fromEnvironment('BERSERK');
static const int _envContacts = int.fromEnvironment(
'DEBUG_CONTACTS',
defaultValue: -1,
);
static const String _flag = '--debug-test';
static const String _berserkFlag = '--berserk';
static const String _contactsFlag = '--contacts';
static void parse(List<String> args) {
if (_envEnabled) enabled = true;
if (_envBerserk) berserk = true;
if (_envContacts >= 0) {
enabled = true;
contactCount = _envContacts;
}
for (var i = 0; i < args.length; i++) {
final arg = args[i];
if (arg == _flag) {
enabled = true;
} else if (arg == _berserkFlag) {
berserk = true;
} else if (arg.startsWith('$_contactsFlag=')) {
enabled = true;
contactCount =
int.tryParse(arg.substring(_contactsFlag.length + 1)) ??
contactCount;
} else if (arg == _contactsFlag && i + 1 < args.length) {
enabled = true;
contactCount = int.tryParse(args[i + 1]) ?? contactCount;
i++;
}
}
}
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
// #***! реальные данные устройства для юзерагента
/// Данные устройства для user-agent, неизменные за время жизни процесса.
/// Читаются с платформы один раз: на каждый реконнект их запрашивать незачем.
class DeviceProfile {
const DeviceProfile({
this.osVersion = '',
this.deviceName = 'Unknown',
this.manufacturer,
this.model,
this.sdkInt,
});
final String osVersion;
final String deviceName;
final String? manufacturer;
final String? model;
final int? sdkInt;
// #***! читаем один раз за процесс, на каждый реконнект незачем
static DeviceProfile? _cached;
static Future<DeviceProfile> load() async {
final cached = _cached;
if (cached != null) return cached;
final profile = await _read();
_cached = profile;
return profile;
}
// #***! у каждой платформы свой источник, на незнакомой пусто
static Future<DeviceProfile> _read() async {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final info = await deviceInfo.androidInfo;
return DeviceProfile(
osVersion: 'Android ${info.version.release}',
deviceName: '${info.manufacturer} ${info.model}',
manufacturer: info.manufacturer,
model: info.model,
sdkInt: info.version.sdkInt,
);
}
if (Platform.isIOS) {
final info = await deviceInfo.iosInfo;
return DeviceProfile(
osVersion: info.systemVersion,
deviceName: info.utsname.machine,
);
}
if (Platform.isLinux) {
final info = await deviceInfo.linuxInfo;
return DeviceProfile(osVersion: info.name);
}
if (Platform.isWindows) {
final info = await deviceInfo.windowsInfo;
return DeviceProfile(osVersion: info.productName);
}
return const DeviceProfile();
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:convert';
import 'dart:typed_data';
abstract final class FontFileInfo {
static const int _collectionTag = 0x74746366;
static const int _typographicFamily = 16;
static const int _family = 1;
static const int _platformUnicode = 0;
static const int _platformMac = 1;
static const int _platformWindows = 3;
static const int _englishUs = 0x0409;
static String? familyName(Uint8List bytes) {
try {
final data = ByteData.sublistView(bytes);
if (bytes.length < 12) return null;
final fontOffset = data.getUint32(0) == _collectionTag
? data.getUint32(12)
: 0;
final nameTable = _tableOffset(bytes, data, fontOffset, 'name');
if (nameTable == null) return null;
return _pick(bytes, data, nameTable, _typographicFamily) ??
_pick(bytes, data, nameTable, _family);
} catch (_) {
return null;
}
}
static int? _tableOffset(
Uint8List bytes,
ByteData data,
int fontOffset,
String tag,
) {
final numTables = data.getUint16(fontOffset + 4);
for (var i = 0; i < numTables; i++) {
final record = fontOffset + 12 + i * 16;
if (record + 16 > bytes.length) return null;
if (String.fromCharCodes(bytes, record, record + 4) == tag) {
return data.getUint32(record + 8);
}
}
return null;
}
static String? _pick(Uint8List bytes, ByteData data, int table, int nameId) {
final count = data.getUint16(table + 2);
final storage = table + data.getUint16(table + 4);
String? fallback;
for (var i = 0; i < count; i++) {
final record = table + 6 + i * 12;
if (record + 12 > bytes.length) break;
if (data.getUint16(record + 6) != nameId) continue;
final platform = data.getUint16(record);
final language = data.getUint16(record + 4);
final length = data.getUint16(record + 8);
final start = storage + data.getUint16(record + 10);
if (start + length > bytes.length) continue;
final raw = bytes.sublist(start, start + length);
final value = switch (platform) {
_platformUnicode || _platformWindows => _utf16be(raw),
_platformMac => latin1.decode(raw, allowInvalid: true),
_ => null,
}?.trim();
if (value == null || value.isEmpty) continue;
if (platform == _platformWindows && language == _englishUs) {
return value;
}
fallback ??= value;
}
return fallback;
}
static String _utf16be(Uint8List raw) {
final units = <int>[
for (var i = 0; i + 1 < raw.length; i += 2) (raw[i] << 8) | raw[i + 1],
];
return String.fromCharCodes(units);
}
static String familyFromFileName(String fileName) {
final base = fileName.split(RegExp(r'[\\/]')).last;
final dot = base.lastIndexOf('.');
final stem = dot > 0 ? base.substring(0, dot) : base;
final cleaned = stem
.replaceAll(
RegExp(
r'[-_ ]?(Regular|Bold|Italic|Medium|Light|Thin|Black|'
r'SemiBold|ExtraBold|ExtraLight|VariableFont.*|Variable)$',
caseSensitive: false,
),
'',
)
.replaceAll(RegExp(r'[_-]+'), ' ')
.trim();
return cleaned.isEmpty ? stem : cleaned;
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:typed_data';
abstract class FontMetrics {
static const double referenceXHeight = 0.528;
static const double minScale = 0.85;
static const double maxScale = 1.15;
static double scaleForXHeight(double xHeightRatio) {
if (xHeightRatio <= 0) return 1.0;
return (referenceXHeight / xHeightRatio)
.clamp(minScale, maxScale)
.toDouble();
}
static double? xHeightRatio(Uint8List bytes) {
try {
if (bytes.length < 12) return null;
final data = ByteData.sublistView(bytes);
if (data.getUint32(0) == 0x74746366) return null;
final numTables = data.getUint16(4);
int? headOffset;
int? os2Offset;
for (var i = 0; i < numTables; i++) {
final record = 12 + i * 16;
if (record + 16 > bytes.length) return null;
final tag = String.fromCharCodes(bytes, record, record + 4);
if (tag == 'head') headOffset = data.getUint32(record + 8);
if (tag == 'OS/2') os2Offset = data.getUint32(record + 8);
}
if (headOffset == null || os2Offset == null) return null;
if (headOffset + 20 > bytes.length || os2Offset + 88 > bytes.length) {
return null;
}
final unitsPerEm = data.getUint16(headOffset + 18);
if (unitsPerEm == 0) return null;
if (data.getUint16(os2Offset) < 2) return null;
final xHeight = data.getInt16(os2Offset + 86);
if (xHeight <= 0) return null;
return xHeight / unitsPerEm;
} catch (_) {
return null;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
/// What every iOS build leaves out or does differently.
///
/// Plugins, the update check and device spoofing depend on the App Store
/// build flag instead, see the App Store build configuration.
///
/// Runtime checks on [defaultTargetPlatform] so Android and desktop keep
/// their behavior and tests can switch platforms with
/// `debugDefaultTargetPlatformOverride`.
abstract final class IosRelease {
static bool get isIOS =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
/// Contact exchange by tapping phones runs on Android host card
/// emulation; iOS has no counterpart, so the entry point is hidden.
static bool get nfcContactExchange => !isIOS;
/// iOS always shows the message long-press menu as a list next to the
/// message, as context menus do in iOS; the radial style and its setting
/// are not offered.
static bool get messageActionsStyleChoice => !isIOS;
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
typedef PrefReader<T> = T? Function(SharedPreferences prefs, String key);
typedef PrefWriter<T> =
Future<void> Function(SharedPreferences prefs, String key, T value);
class PersistedSetting<T> {
PersistedSetting({
required this.prefKey,
required this.defaultValue,
required this.read,
required this.write,
T Function(T value)? sanitize,
}) : sanitize = sanitize ?? ((value) => value),
current = ValueNotifier<T>(defaultValue);
final String prefKey;
final T defaultValue;
final PrefReader<T> read;
final PrefWriter<T> write;
final T Function(T value) sanitize;
final ValueNotifier<T> current;
Future<T> load() async {
final prefs = await SharedPreferences.getInstance();
final value = sanitize(read(prefs, prefKey) ?? defaultValue);
current.value = value;
return value;
}
Future<void> save(T value) async {
final sanitized = sanitize(value);
current.value = sanitized;
final prefs = await SharedPreferences.getInstance();
await write(prefs, prefKey, sanitized);
}
}
typedef EnumEncoder<T extends Enum> = String Function(T value);
typedef EnumDecoder<T extends Enum> = T Function(String? raw);
T enumFromName<T extends Enum>(List<T> values, String? raw, T fallback) =>
values.firstWhere((v) => v.name == raw, orElse: () => fallback);
class PersistedEnum<T extends Enum> {
PersistedEnum({
required this.prefKey,
required this.defaultValue,
required this.encode,
required this.decode,
}) : current = ValueNotifier<T>(defaultValue);
final String prefKey;
final T defaultValue;
final EnumEncoder<T> encode;
final EnumDecoder<T> decode;
final ValueNotifier<T> current;
Future<T> load() async {
final prefs = await SharedPreferences.getInstance();
final value = decode(prefs.getString(prefKey));
current.value = value;
return value;
}
Future<void> save(T value) async {
current.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, encode(value));
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import '../storage/token_storage.dart';
import '../utils/logger.dart';
enum ProxyType { none, socks5, httpConnect }
class ProxySettings {
final ProxyType type;
final String host;
final int port;
final String? username;
final String? password;
const ProxySettings({
this.type = ProxyType.none,
this.host = '',
this.port = 1080,
this.username,
this.password,
});
bool get isEnabled => type != ProxyType.none && host.isNotEmpty;
bool get hasCredentials =>
username != null &&
username!.isNotEmpty &&
password != null &&
password!.isNotEmpty;
}
abstract class ProxyConfig {
static const String _prefType = 'proxy_type';
static const String _prefHost = 'proxy_host';
static const String _prefPort = 'proxy_port';
static const String _prefUsername = 'proxy_username';
static const String _prefPassword = 'proxy_password';
static const Duration _secureReadTimeout = Duration(seconds: 5);
static Future<String?> _readSecureSafe(String key) async {
try {
return await TokenStorage.readSecure(key).timeout(_secureReadTimeout);
} catch (e) {
logger.w('ProxyConfig: чтение secure "$key" не удалось/зависло: $e');
return null;
}
}
static Future<void> _migrateLegacySecure(String key, String value) async {
try {
await TokenStorage.writeSecure(key, value).timeout(_secureReadTimeout);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(key);
} catch (e) {
logger.w('ProxyConfig: миграция legacy "$key" не удалась: $e');
}
}
static Future<ProxySettings> load() async {
final prefs = await SharedPreferences.getInstance();
final typeIndex = prefs.getInt(_prefType) ?? 0;
final host = prefs.getString(_prefHost) ?? '';
final port = prefs.getInt(_prefPort) ?? 1080;
if (ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)] ==
ProxyType.none ||
host.isEmpty) {
return ProxySettings(
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
host: host,
port: port,
);
}
var username = await _readSecureSafe(_prefUsername);
var password = await _readSecureSafe(_prefPassword);
final legacyUsername = prefs.getString(_prefUsername);
final legacyPassword = prefs.getString(_prefPassword);
if (username == null && legacyUsername != null) {
username = legacyUsername;
unawaited(_migrateLegacySecure(_prefUsername, legacyUsername));
}
if (password == null && legacyPassword != null) {
password = legacyPassword;
unawaited(_migrateLegacySecure(_prefPassword, legacyPassword));
}
return ProxySettings(
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
host: host,
port: port,
username: username,
password: password,
);
}
static Future<void> save(ProxySettings settings) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prefType, settings.type.index);
await prefs.setString(_prefHost, settings.host);
await prefs.setInt(_prefPort, settings.port);
await prefs.remove(_prefUsername);
await prefs.remove(_prefPassword);
if (settings.username != null) {
await TokenStorage.writeSecure(_prefUsername, settings.username!);
} else {
await TokenStorage.deleteSecure(_prefUsername);
}
if (settings.password != null) {
await TokenStorage.writeSecure(_prefPassword, settings.password!);
} else {
await TokenStorage.deleteSecure(_prefPassword);
}
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefType);
await prefs.remove(_prefHost);
await prefs.remove(_prefPort);
await prefs.remove(_prefUsername);
await prefs.remove(_prefPassword);
await TokenStorage.deleteSecure(_prefUsername);
await TokenStorage.deleteSecure(_prefPassword);
}
}
+119
View File
@@ -0,0 +1,119 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class QlyraSettings {
static const _kViewDeleted = 'qlyra_view_deleted';
static const _kViewRedacted = 'qlyra_view_redacted';
static const _kFullTimestamp = 'qlyra_full_timestamp';
static const _kShowForward = 'qlyra_show_forward';
static const _kShowTypingTime = 'qlyra_show_typing_time';
static const _kGhostMode = 'qlyra_ghost_mode';
static const _kAntiRead = 'qlyra_anti_read';
static const _kSelfOnlineCheck = 'qlyra_self_online_check';
static const _kHideAllChatsFolder = 'qlyra_hide_all_chats_folder';
static const _kShowHiddenChats = 'qlyra_show_hidden_chats';
static const _kArchiveOnPull = 'qlyra_archive_on_pull';
static const _kRecordDebugLogs = 'qlyra_record_debug_logs';
static final ValueNotifier<bool> viewDeleted = ValueNotifier(false);
static final ValueNotifier<bool> viewRedacted = ValueNotifier(false);
static final ValueNotifier<bool> fullTimestamp = ValueNotifier(false);
static final ValueNotifier<bool> showForward = ValueNotifier(false);
static final ValueNotifier<bool> showTypingTime = ValueNotifier(false);
static final ValueNotifier<bool> ghostMode = ValueNotifier(false);
static final ValueNotifier<bool> antiRead = ValueNotifier(false);
static final ValueNotifier<bool> selfOnlineCheck = ValueNotifier(true);
static final ValueNotifier<bool> hideAllChatsFolder = ValueNotifier(false);
static final ValueNotifier<bool> recordDebugLogs = ValueNotifier(true);
static final ValueNotifier<bool> archiveOnPull = ValueNotifier(false);
static final ValueNotifier<bool> showHiddenChats = ValueNotifier(false);
static Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
recordDebugLogs.value = prefs.getBool(_kRecordDebugLogs) ?? true;
archiveOnPull.value = prefs.getBool(_kArchiveOnPull) ?? false;
viewDeleted.value = prefs.getBool(_kViewDeleted) ?? false;
viewRedacted.value = prefs.getBool(_kViewRedacted) ?? false;
fullTimestamp.value = prefs.getBool(_kFullTimestamp) ?? false;
showForward.value = prefs.getBool(_kShowForward) ?? false;
showTypingTime.value = prefs.getBool(_kShowTypingTime) ?? false;
ghostMode.value = prefs.getBool(_kGhostMode) ?? false;
antiRead.value = prefs.getBool(_kAntiRead) ?? false;
selfOnlineCheck.value = prefs.getBool(_kSelfOnlineCheck) ?? true;
hideAllChatsFolder.value = prefs.getBool(_kHideAllChatsFolder) ?? false;
showHiddenChats.value = prefs.getBool(_kShowHiddenChats) ?? false;
}
static Future<void> setRecordDebugLogs(bool value) async {
recordDebugLogs.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kRecordDebugLogs, value);
}
static Future<void> setArchiveOnPull(bool value) async {
archiveOnPull.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kArchiveOnPull, value);
}
static Future<void> setViewDeleted(bool value) async {
viewDeleted.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kViewDeleted, value);
}
static Future<void> setViewRedacted(bool value) async {
viewRedacted.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kViewRedacted, value);
}
static Future<void> setFullTimestamp(bool value) async {
fullTimestamp.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kFullTimestamp, value);
}
static Future<void> setShowForward(bool value) async {
showForward.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowForward, value);
}
static Future<void> setShowTypingTime(bool value) async {
showTypingTime.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowTypingTime, value);
}
// #***! невидимка влияет на пинг, с interactive false сервер не считает нас онлайн
static Future<void> setGhostMode(bool value) async {
ghostMode.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kGhostMode, value);
}
static Future<void> setAntiRead(bool value) async {
antiRead.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kAntiRead, value);
}
static Future<void> setSelfOnlineCheck(bool value) async {
selfOnlineCheck.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSelfOnlineCheck, value);
}
static Future<void> setHideAllChatsFolder(bool value) async {
hideAllChatsFolder.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kHideAllChatsFolder, value);
}
static Future<void> setShowHiddenChats(bool value) async {
showHiddenChats.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowHiddenChats, value);
}
}
+59
View File
@@ -0,0 +1,59 @@
import '../utils/format.dart';
import '../utils/names.dart';
import 'device_contacts_service.dart';
// #***! заголовок и подпись строки контакта
class ContactLabels {
final String title;
final String? subtitle;
const ContactLabels({required this.title, this.subtitle});
}
// #***! имя из телефонной книги важнее серверного
String? contactName({Object? firstName, Object? lastName, Object? phone}) {
final book = phone is int ? DeviceContactsService.nameForPhone(phone) : null;
if (book != null && book.isNotEmpty) return book;
final full = displayName(firstName, lastName);
return full.isEmpty ? null : full;
}
// #***! сортируем по имени, нет так по телефону
String contactSortKey({Object? firstName, Object? lastName, Object? phone}) =>
contactName(firstName: firstName, lastName: lastName, phone: phone) ??
formatPhone(phone) ??
'';
// #***! подпись не дублируем
ContactLabels contactLabels({
required String idLabel,
Object? firstName,
Object? lastName,
Object? phone,
}) {
final phoneLabel = formatPhone(phone);
final title =
contactName(firstName: firstName, lastName: lastName, phone: phone) ??
phoneLabel ??
idLabel;
final subtitle = phoneLabel ?? idLabel;
return ContactLabels(
title: title,
subtitle: subtitle == title ? null : subtitle,
);
}
bool contactMatchesQuery(
String query, {
required String title,
Object? firstName,
Object? lastName,
Object? phone,
}) {
final needle = query.trim().toLowerCase();
if (needle.isEmpty) return true;
final names = [title, displayName(firstName, lastName)];
if (names.any((name) => name.toLowerCase().contains(needle))) return true;
final digits = needle.replaceAll(RegExp(r'\D'), '');
return digits.length >= 3 && phone is int && '$phone'.contains(digits);
}
+14
View File
@@ -0,0 +1,14 @@
enum ContactPresence { unknown, online, recently, longAgo }
ContactPresence contactPresence(Map<String, dynamic>? presence, DateTime now) {
if (presence == null) return ContactPresence.unknown;
final status = presence['status'];
if (status == 1) return ContactPresence.online;
if (status == 2 || status == 3) return ContactPresence.recently;
final seen = presence['seen'];
if (seen is! int || seen <= 0) return ContactPresence.unknown;
final elapsed = now.millisecondsSinceEpoch ~/ 1000 - seen;
return elapsed <= const Duration(days: 3).inSeconds
? ContactPresence.recently
: ContactPresence.longAgo;
}
+52
View File
@@ -0,0 +1,52 @@
import '../../backend/modules/contacts.dart';
class ContactSearch {
static List<MapEntry<String, String>> filterPhonebook(
Map<String, String> phonebook,
String query,
) {
final matches = filter([
for (final entry in phonebook.entries)
CachedContact(
id: 0,
accountId: 0,
firstName: entry.value,
phone: int.parse(entry.key.replaceAll('+', '')),
updateTime: 0,
),
], query);
return [
for (final contact in matches)
MapEntry('+${contact.phone}', contact.firstName),
]..sort(
(a, b) => _normalizeName(a.value).compareTo(_normalizeName(b.value)),
);
}
static List<CachedContact> filter(
List<CachedContact> contacts,
String query,
) {
final text = _normalizeName(query);
if (text.isEmpty) return List.of(contacts);
final isPhone = RegExp(r'^[+\d\s().-]+$').hasMatch(text);
var digits = isPhone ? text.replaceAll(RegExp(r'\D'), '') : '';
if (digits.length == 11 && digits.startsWith('8')) {
digits = '7${digits.substring(1)}';
}
final words = text.split(' ');
return contacts.where((contact) {
final name = _normalizeName(
'${contact.firstName} ${contact.lastName ?? ''}',
);
return words.every(name.contains) ||
(digits.isNotEmpty && contact.phone.toString().contains(digits));
}).toList();
}
static String _normalizeName(String value) => value
.trim()
.toLowerCase()
.replaceAll('ё', 'е')
.replaceAll(RegExp(r'\s+'), ' ');
}
@@ -0,0 +1,147 @@
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 _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() async {
_loaded = false;
_byLast10.clear();
_phonebook.clear();
return ensureLoadedInteractive(force: true);
}
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 {
_loaded = false;
_byLast10.clear();
_phonebook.clear();
try {
FlutterContacts.config.includeNonVisibleOnAndroid = true;
final contacts = await FlutterContacts.getContacts(withProperties: true);
_byLast10.clear();
_phonebook.clear();
for (final contact in contacts) {
final name = contact.displayName.trim();
for (final phone in contact.phones) {
final normalized = normalizePhone(phone.number);
if (normalized != null) {
_phonebook.putIfAbsent(
normalized,
() => name.isEmpty ? normalized : name,
);
}
final key = _last10(phone.number);
if (key != null) {
_byLast10.putIfAbsent(key, () => name);
}
}
}
_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';
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
import '../../backend/modules/contacts.dart';
import 'device_contacts_service.dart';
/// Imports only discoverable MAX accounts, without renaming existing contacts.
class PhonebookContactSync {
static Future<int> importContacts({
required Map<String, String> phonebook,
required List<CachedContact> contacts,
required int accountId,
required Future<PhoneLookupResult?> Function(String) lookup,
required Future<AddContactResult> Function(String, String) add,
required bool Function() isCurrent,
Duration requestDelay = const Duration(milliseconds: 250),
}) async {
final ids = contacts.map((c) => c.id).toSet()..add(accountId);
final phones = contacts
.map((c) => DeviceContactsService.normalizePhone('${c.phone}'))
.whereType<String>()
.toSet();
var added = 0;
for (final entry in phonebook.entries) {
if (!isCurrent()) break;
final phone = DeviceContactsService.normalizePhone(entry.key);
if (phone == null || !phones.add(phone)) continue;
await Future<void>.delayed(requestDelay);
if (!isCurrent()) break;
// Strict lookup errors stop this pass; absent accounts are retried next time.
final found = await lookup(phone);
if (!isCurrent()) break;
if (found == null || ids.contains(found.id)) continue;
final name = entry.value.trim();
final result = await add(
phone,
(name.isEmpty ? phone : name).characters.take(60).toString(),
);
if (!isCurrent()) break;
if (result.status == AddContactStatus.error) {
throw StateError('Phonebook contact import failed');
}
if (result.status == AddContactStatus.added) {
ids.add(found.id);
if (result.contact != null) ids.add(result.contact!.id);
added++;
}
}
return added;
}
}
@@ -0,0 +1,44 @@
import '../../backend/modules/contacts.dart';
import 'device_contacts_service.dart';
class PhonebookMaxContacts {
static Future<List<CachedContact>> resolve({
required Map<String, String> phonebook,
required List<CachedContact> cached,
required int accountId,
required Future<PhoneLookupResult?> Function(String) lookup,
bool Function()? isCancelled,
}) async {
final known = <String, CachedContact>{};
for (final contact in cached) {
final phone = DeviceContactsService.normalizePhone('${contact.phone}');
if (phone != null) known[phone] = contact;
}
final result = <int, CachedContact>{};
for (final entry in phonebook.entries) {
if (isCancelled?.call() == true) break;
final phone = DeviceContactsService.normalizePhone(entry.key);
if (phone == null) continue;
final existing = known[phone];
final found = await lookup(phone);
if (isCancelled?.call() == true) break;
if (found == null) continue;
final id = found.id;
result.putIfAbsent(
id,
() => CachedContact(
id: id,
accountId: accountId,
firstName: entry.value,
phone: int.parse(phone.substring(1)),
baseUrl: found.avatarUrl,
updateTime: 0,
options: existing?.id == id ? existing!.options : const {},
),
);
}
return result.values.toList()..sort(
(a, b) => a.firstName.toLowerCase().compareTo(b.firstName.toLowerCase()),
);
}
}
@@ -0,0 +1,58 @@
import 'dart:typed_data';
// #***! кэш ключей чатов: clear сдвигает поколение, и вывод, начатый до сброса,
// #***! в кэш уже не попадает
class ChatCryptoKeyCache {
ChatCryptoKeyCache(this._derive, {required void Function(Uint8List key) wipe})
: _wipe = wipe;
final Future<Uint8List?> Function(int accountId, int chatId) _derive;
final void Function(Uint8List key) _wipe;
final Map<String, Uint8List> _keys = {};
final Map<String, Future<Uint8List?>> _pending = {};
int _generation = 0;
void clear() {
_generation++;
for (final key in _keys.values) {
_wipe(key);
}
_keys.clear();
_pending.clear();
}
Future<Uint8List?> keyFor(int accountId, int chatId) {
final cacheKey = '$accountId/$chatId';
final generation = _generation;
final cached = _keys[cacheKey];
if (cached != null) {
return Future.value(
cached,
).then((key) => generation == _generation ? key : null);
}
final existing = _pending[cacheKey];
if (existing != null) return existing;
late final Future<Uint8List?> pending;
pending = _deriveKey(accountId, chatId, cacheKey, generation).whenComplete(
() {
if (identical(_pending[cacheKey], pending)) _pending.remove(cacheKey);
},
);
return _pending[cacheKey] = pending;
}
Future<Uint8List?> _deriveKey(
int accountId,
int chatId,
String cacheKey,
int generation,
) async {
final key = await _derive(accountId, chatId);
if (key == null) return null;
if (generation != _generation) {
_wipe(key);
return null;
}
return _keys[cacheKey] = key;
}
}
+206
View File
@@ -0,0 +1,206 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:komet_crypto/komet_crypto.dart';
import 'package:qlyra_crypto/qlyra_crypto.dart' as legacy;
import '../storage/chat_encryption_store.dart';
import '../utils/logger.dart';
import 'noise_png.dart';
import 'chat_crypto_key_cache.dart';
Future<void>? _legacyInitialization;
Future<Uint8List> _derivePassword(String password) async {
if (!legacy.isInitialized) {
await (_legacyInitialization ??= legacy.RustLib.init());
}
return legacy.deriveKey(password: password);
}
const int kMaxEncryptedMessageLength = 1000;
enum CryptoFailure { noKey, wrongKey, notEncrypted, malformed, unavailable }
class CryptoResult {
final String? text;
final CryptoFailure? failure;
const CryptoResult.ok(String this.text) : failure = null;
const CryptoResult.failed(CryptoFailure this.failure) : text = null;
bool get isOk => text != null;
}
// #***! байты либо причина неудачи
class CryptoBytesResult {
final Uint8List? bytes;
final CryptoFailure? failure;
const CryptoBytesResult.ok(Uint8List this.bytes) : failure = null;
const CryptoBytesResult.failed(CryptoFailure this.failure) : bytes = null;
bool get isOk => bytes != null;
}
CryptoFailure cryptoFailureOf(Object error) {
if (error is KometCryptoException) {
switch (error.status) {
case CryptoStatus.wrongKey:
return CryptoFailure.wrongKey;
case CryptoStatus.notEncrypted:
return CryptoFailure.notEncrypted;
case CryptoStatus.malformed:
return CryptoFailure.malformed;
default:
return CryptoFailure.unavailable;
}
}
return CryptoFailure.unavailable;
}
// #***! парольное шифрование групп и старой истории, сама крипта в C-ядре
class ChatCryptoService {
ChatCryptoService._() {
ChatEncryptionStore.instance.revision.addListener(clearKeys);
}
static final ChatCryptoService instance = ChatCryptoService._();
late final ChatCryptoKeyCache _keyCache = ChatCryptoKeyCache(
_deriveKey,
wipe: KometCrypto.wipe,
);
bool get _unavailable => !KometCrypto.isAvailable;
void clearKeys() => _keyCache.clear();
Future<Uint8List?> _keyFor(int accountId, int chatId) =>
_keyCache.keyFor(accountId, chatId);
Future<Uint8List?> _deriveKey(int accountId, int chatId) async {
try {
if (_unavailable) return null;
final password = await ChatEncryptionStore.instance.readKey(
accountId,
chatId,
);
if (password == null || password.isEmpty) return null;
return await _derivePassword(password);
} catch (e) {
logger.w('derive key for chat $chatId: $e');
return null;
}
}
bool isEnabled(int accountId, int chatId) =>
ChatEncryptionStore.instance.isEnabled(accountId, chatId);
Future<void> warmKey(int accountId, int chatId) => _keyFor(accountId, chatId);
CryptoFailure _noKeyFailure() =>
_unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey;
// #***! шифрование и расшифровка текста
Future<CryptoResult> encrypt(
int accountId,
int chatId,
String plaintext,
) async {
final key = await _keyFor(accountId, chatId);
if (key == null) return CryptoResult.failed(_noKeyFailure());
try {
return CryptoResult.ok(KometCrypto.encryptMessage(plaintext, key));
} catch (e) {
logger.w('encrypt for chat $chatId: $e');
return const CryptoResult.failed(CryptoFailure.unavailable);
}
}
Future<CryptoResult> decrypt(int accountId, int chatId, String text) async {
final key = await _keyFor(accountId, chatId);
if (key == null) return CryptoResult.failed(_noKeyFailure());
try {
return CryptoResult.ok(KometCrypto.decryptMessage(text, key));
} catch (e) {
return CryptoResult.failed(cryptoFailureOf(e));
}
}
// #***! картинки байт в байт, шум заворачивается в PNG уже здесь
Future<CryptoBytesResult> encryptImageBytes(
int accountId,
int chatId,
Uint8List png,
) async {
final key = await _keyFor(accountId, chatId);
if (key == null) return CryptoBytesResult.failed(_noKeyFailure());
try {
final blob = KometCrypto.encryptImageBlob(png, key);
final wrapped = wrapNoisePng(blob);
if (wrapped == null) {
return const CryptoBytesResult.failed(CryptoFailure.malformed);
}
return CryptoBytesResult.ok(wrapped);
} catch (e) {
logger.w('image encrypt for chat $chatId: $e');
return CryptoBytesResult.failed(cryptoFailureOf(e));
}
}
Future<CryptoBytesResult> decryptImageBytes(
int accountId,
int chatId,
Uint8List noisePng,
) async {
final key = await _keyFor(accountId, chatId);
if (key == null) return CryptoBytesResult.failed(_noKeyFailure());
final raw = unwrapNoisePng(noisePng);
if (raw == null) {
return const CryptoBytesResult.failed(CryptoFailure.notEncrypted);
}
try {
return CryptoBytesResult.ok(KometCrypto.decryptImageBlob(raw, key));
} catch (e) {
logger.w('image decrypt for chat $chatId: $e');
return CryptoBytesResult.failed(cryptoFailureOf(e));
}
}
// #***! похоже ли это вообще на наш шифр, чтоб отличить чужой ключ от обычного текста
bool looksEncryptedImage(Uint8List noisePng) {
if (_unavailable) return false;
final raw = unwrapNoisePng(noisePng);
if (raw == null) return false;
try {
return KometCrypto.looksEncryptedImageBlob(raw);
} catch (_) {
return false;
}
}
// #***! разовая расшифровка чужим паролем, ключ не кэшируем
Future<String?> decryptWithPassword(String text, String password) async {
if (_unavailable) return null;
try {
final key = await _derivePassword(password);
try {
return KometCrypto.decryptMessage(text, key);
} finally {
KometCrypto.wipe(key);
}
} catch (_) {
return null;
}
}
bool looksEncrypted(String text) {
if (_unavailable) return false;
try {
return KometCrypto.looksEncryptedMessage(text);
} catch (_) {
return false;
}
}
}
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:komet_crypto/komet_crypto.dart';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import '../utils/logger.dart';
import '../utils/media_cache.dart';
import 'chat_crypto_service.dart';
// #***! парольное фото уходит на сервер как png, сквозное как шифртекст без обёртки
const String kEncryptedPhotoExtension = '.png';
const String kE2eePhotoExtension = '.kce';
const int _ticketHeader =
KometCryptoSizes.fileKey + KometCryptoSizes.fileNonce + 8;
// #***! имя из билета а не из дескриптора, иначе сервер подсунет чужую расшифровку
String decryptedCacheName(int accountId, int chatId, String cacheName) =>
'decrypted_${accountId}_${chatId}_$cacheName';
String e2eeDecryptedCacheName(Uint8List ticket) {
final digest = sha256.convert(ticket.sublist(0, _ticketHeader));
return 'decrypted_e2ee_${digest.toString().substring(0, 40)}';
}
class EncryptedPhotoResult {
final File? file;
final CryptoFailure? failure;
const EncryptedPhotoResult.ok(File this.file) : failure = null;
const EncryptedPhotoResult.failed(CryptoFailure this.failure) : file = null;
bool get isOk => file != null;
}
// #***! перед шифрованием гоним в png в памяти, жпег после перекодирования байт в байт не совпадёт
Future<Uint8List?> reencodeAsPng(File source) async {
try {
final bytes = await source.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final data = await frame.image.toByteData(format: ui.ImageByteFormat.png);
frame.image.dispose();
codec.dispose();
return data?.buffer.asUint8List();
} catch (e) {
logger.w('png re-encode failed: $e');
return null;
}
}
// #***! временная папка под шифртекст, открытого текста на диске нет
Future<Directory> _scratchDir() async {
final dir = Directory('${(await getTemporaryDirectory()).path}/qlyra_enc');
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
Future<EncryptedPhotoResult> prepareEncryptedPhoto({
required int accountId,
required int chatId,
required File source,
required String stamp,
}) async {
final png = await reencodeAsPng(source);
if (png == null) {
return const EncryptedPhotoResult.failed(CryptoFailure.malformed);
}
final result = await ChatCryptoService.instance.encryptImageBytes(
accountId,
chatId,
png,
);
if (!result.isOk) return EncryptedPhotoResult.failed(result.failure!);
final dir = await _scratchDir();
final encrypted = File('${dir.path}/enc_$stamp.png');
await encrypted.writeAsBytes(result.bytes!, flush: true);
return EncryptedPhotoResult.ok(encrypted);
}
// #***! скачанный шум обратно в фото, кладём в кэш просмотрщика
Future<EncryptedPhotoResult> openEncryptedPhoto({
required int accountId,
required int chatId,
required File encrypted,
required String cacheName,
}) async {
final target = await MediaCache.fileFor(
decryptedCacheName(accountId, chatId, cacheName),
);
if (await target.exists() && await target.length() > 0) {
return EncryptedPhotoResult.ok(target);
}
final result = await ChatCryptoService.instance.decryptImageBytes(
accountId,
chatId,
await encrypted.readAsBytes(),
);
if (!result.isOk) return EncryptedPhotoResult.failed(result.failure!);
await target.writeAsBytes(result.bytes!, flush: true);
return EncryptedPhotoResult.ok(target);
}
// #***! сквозное фото: свой ключ на файл, билет едет внутри рэтчет-сообщения
class E2eePhotoPrepared {
final File file;
final Uint8List ticket;
const E2eePhotoPrepared(this.file, this.ticket);
}
Future<E2eePhotoPrepared?> prepareE2eePhoto({
required File source,
required String stamp,
}) async {
final png = await reencodeAsPng(source);
if (png == null) return null;
final key = KometCrypto.randomBytes(KometCryptoSizes.fileKey);
final nonce = KometCrypto.randomBytes(KometCryptoSizes.fileNonce);
final out = BytesBuilder(copy: false);
try {
final sealer = KometCrypto.fileSealer(key, nonce);
var offset = 0;
while (true) {
final last = png.length - offset <= KometCryptoSizes.fileChunk;
final end = last ? png.length : offset + KometCryptoSizes.fileChunk;
out.add(sealer.chunk(png.sublist(offset, end), last: last));
offset = end;
if (last) break;
}
sealer.dispose();
} catch (e) {
logger.w('e2ee photo seal: $e');
return null;
}
final dir = await _scratchDir();
final file = File('${dir.path}/enc_$stamp$kE2eePhotoExtension');
await file.writeAsBytes(out.toBytes(), flush: true);
final name = Uint8List.fromList(utf8.encode('photo_$stamp.png'));
final ticket = Uint8List(_ticketHeader + name.length);
ticket.setAll(0, key);
ticket.setAll(KometCryptoSizes.fileKey, nonce);
var size = png.length;
for (var i = _ticketHeader - 1; i >= _ticketHeader - 8; i--) {
ticket[i] = size & 0xff;
size >>= 8;
}
ticket.setAll(_ticketHeader, name);
KometCrypto.wipe(key);
return E2eePhotoPrepared(file, ticket);
}
Future<EncryptedPhotoResult> openE2eePhoto({
required File encrypted,
required Uint8List ticket,
}) async {
if (ticket.length < _ticketHeader) {
return const EncryptedPhotoResult.failed(CryptoFailure.malformed);
}
final target = await MediaCache.fileFor(e2eeDecryptedCacheName(ticket));
var expectedSize = 0;
for (final value in ticket.sublist(_ticketHeader - 8, _ticketHeader)) {
expectedSize = (expectedSize << 8) | value;
}
if (await target.exists() && await target.length() > 0) {
return EncryptedPhotoResult.ok(target);
}
final key = ticket.sublist(0, KometCryptoSizes.fileKey);
final nonce = ticket.sublist(KometCryptoSizes.fileKey, _ticketHeader - 8);
const step = KometCryptoSizes.fileChunk + KometCryptoSizes.tag;
final total = await encrypted.length();
// #***! блоками, размер выбирает сервер и в память вложение не влезет
final source = await encrypted.open();
final partial = File(
'${target.path}.${DateTime.now().microsecondsSinceEpoch}.part',
);
final sink = partial.openWrite();
try {
final opener = KometCrypto.fileOpener(key, nonce);
try {
var offset = 0;
while (true) {
final last = total - offset <= step;
final take = last ? total - offset : step;
final block = await source.read(take);
if (block.length != take) throw const FormatException('truncated');
sink.add(opener.chunk(block, last: last));
offset += take;
if (last) break;
}
} finally {
opener.dispose();
}
await sink.close();
if (await partial.length() != expectedSize)
throw const FormatException('size mismatch');
await partial.rename(target.path);
} catch (e) {
logger.w('e2ee photo open: $e');
try {
await sink.close();
} catch (_) {}
try {
if (await partial.exists()) await partial.delete();
} catch (_) {}
return EncryptedPhotoResult.failed(cryptoFailureOf(e));
} finally {
KometCrypto.wipe(key);
await source.close();
}
return EncryptedPhotoResult.ok(target);
}
+305
View File
@@ -0,0 +1,305 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../storage/chat_encryption_store.dart';
import 'e2ee_service.dart';
import '../utils/download_progress.dart';
import '../utils/logger.dart';
import '../utils/media_cache.dart';
import 'chat_crypto_service.dart';
import 'encrypted_photo.dart';
typedef EncryptedPhotoUrlLoader = Future<String?> Function();
enum EncryptedPhotoStatus { plain, decrypted, wrongKey, locked }
@immutable
class EncryptedPhotoView {
final File? file;
final EncryptedPhotoStatus status;
const EncryptedPhotoView.plain()
: file = null,
status = EncryptedPhotoStatus.plain;
const EncryptedPhotoView.decrypted(File this.file)
: status = EncryptedPhotoStatus.decrypted;
const EncryptedPhotoView.wrongKey()
: file = null,
status = EncryptedPhotoStatus.wrongKey;
const EncryptedPhotoView.locked()
: file = null,
status = EncryptedPhotoStatus.locked;
bool get isDecrypted => status == EncryptedPhotoStatus.decrypted;
}
class _AutoRequest {
final int accountId;
final int chatId;
final String cacheName;
final EncryptedPhotoUrlLoader urlLoader;
final int size;
final Uint8List? sealedTicket;
const _AutoRequest({
required this.accountId,
required this.chatId,
required this.cacheName,
required this.urlLoader,
required this.size,
required this.sealedTicket,
});
}
class EncryptedPhotoCache {
EncryptedPhotoCache._() {
ChatEncryptionStore.instance.revision.addListener(clear);
}
static final EncryptedPhotoCache instance = EncryptedPhotoCache._();
static const int _maxEntries = 200;
static const int _maxAutoBytes = 32 * 1024 * 1024;
static const int _maxConcurrentAuto = 3;
final LinkedHashMap<String, ValueNotifier<EncryptedPhotoView?>> _entries =
LinkedHashMap();
final Map<String, Future<EncryptedPhotoView>> _inFlight = {};
final Queue<_AutoRequest> _queue = Queue();
final Set<String> _queued = {};
int _running = 0;
ValueListenable<EncryptedPhotoView?> listenableFor(String cacheName) =>
_entryFor(cacheName);
ValueNotifier<EncryptedPhotoView?> _entryFor(String cacheName) =>
_entries[cacheName] ??= ValueNotifier<EncryptedPhotoView?>(null);
void _evictStale(String keep) {
while (_entries.length > _maxEntries) {
final oldest = _entries.keys.first;
if (oldest == keep) break;
_entries.remove(oldest);
}
}
void request({
required int accountId,
required int chatId,
required String cacheName,
required EncryptedPhotoUrlLoader urlLoader,
required int size,
Uint8List? sealedTicket,
}) {
if (sealedTicket == null &&
!ChatCryptoService.instance.isEnabled(accountId, chatId)) {
return;
}
if (_entryFor(cacheName).value != null) return;
if (_inFlight.containsKey(cacheName)) return;
if (!_queued.add(cacheName)) return;
_evictStale(cacheName);
_queue.add(
_AutoRequest(
accountId: accountId,
chatId: chatId,
cacheName: cacheName,
urlLoader: urlLoader,
size: size,
sealedTicket: sealedTicket,
),
);
_pump();
}
Future<EncryptedPhotoView> resolve({
required int accountId,
required int chatId,
required String cacheName,
required EncryptedPhotoUrlLoader urlLoader,
int? size,
Uint8List? sealedTicket,
}) {
final known = _entryFor(cacheName).value;
if (known != null && known.status != EncryptedPhotoStatus.locked) {
return Future.value(known);
}
_dequeue(cacheName);
return _start(accountId, chatId, cacheName, urlLoader, size, sealedTicket);
}
void clear() {
_queue.clear();
_queued.clear();
for (final entry in _entries.values) {
entry.value = null;
}
}
void _dequeue(String cacheName) {
if (!_queued.remove(cacheName)) return;
_queue.removeWhere((request) => request.cacheName == cacheName);
}
void _pump() {
while (_running < _maxConcurrentAuto && _queue.isNotEmpty) {
final next = _queue.removeLast();
_queued.remove(next.cacheName);
_running++;
final done = _start(
next.accountId,
next.chatId,
next.cacheName,
next.urlLoader,
next.size,
next.sealedTicket,
);
unawaited(
done.whenComplete(() {
_running--;
_pump();
}),
);
}
}
Future<EncryptedPhotoView> _start(
int accountId,
int chatId,
String cacheName,
EncryptedPhotoUrlLoader urlLoader,
int? autoSize,
Uint8List? sealedTicket,
) {
final running = _inFlight[cacheName];
if (running != null) return running;
final future = _resolve(
accountId,
chatId,
cacheName,
urlLoader,
autoSize,
sealedTicket,
);
_inFlight[cacheName] = future;
unawaited(future.whenComplete(() => _inFlight.remove(cacheName)));
return future;
}
Future<EncryptedPhotoView> _resolve(
int accountId,
int chatId,
String cacheName,
EncryptedPhotoUrlLoader urlLoader,
int? autoSize,
Uint8List? sealedTicket,
) async {
EncryptedPhotoView view;
try {
view = await _decrypt(
accountId,
chatId,
cacheName,
urlLoader,
autoSize,
sealedTicket,
);
} catch (e) {
logger.w('encrypted preview $cacheName: $e');
view = const EncryptedPhotoView.locked();
}
_entryFor(cacheName).value = view;
return view;
}
Future<EncryptedPhotoView> _decrypt(
int accountId,
int chatId,
String cacheName,
EncryptedPhotoUrlLoader urlLoader,
int? autoSize,
Uint8List? sealedTicket,
) async {
// #***! имя расшифрованного берётся из билета, поэтому билет первым
Uint8List? ticket;
if (sealedTicket != null) {
ticket = await E2eeService.instance.openBytes(
accountId,
chatId,
sealedTicket,
);
if (ticket == null) return const EncryptedPhotoView.locked();
final ready = await MediaCache.existing(e2eeDecryptedCacheName(ticket));
if (ready != null) return EncryptedPhotoView.decrypted(ready);
} else {
final ready = await MediaCache.existing(
decryptedCacheName(accountId, chatId, cacheName),
);
if (ready != null) return EncryptedPhotoView.decrypted(ready);
}
var encrypted = await MediaCache.existing(cacheName);
if (encrypted == null) {
if (autoSize != null && autoSize > _maxAutoBytes) {
return const EncryptedPhotoView.locked();
}
// #***! потолок по объявленному размеру, но не выше нашего собственного
final cap = autoSize == null || autoSize <= 0
? _maxAutoBytes
: (autoSize < _maxAutoBytes ? autoSize : _maxAutoBytes);
encrypted = await _download(cacheName, urlLoader, cap);
}
if (encrypted == null) return const EncryptedPhotoView.locked();
if (ticket != null) {
final opened = await openE2eePhoto(encrypted: encrypted, ticket: ticket);
if (opened.isOk) return EncryptedPhotoView.decrypted(opened.file!);
return opened.failure == CryptoFailure.unavailable
? const EncryptedPhotoView.locked()
: const EncryptedPhotoView.wrongKey();
}
if (!ChatCryptoService.instance.looksEncryptedImage(
await encrypted.readAsBytes(),
)) {
return const EncryptedPhotoView.plain();
}
final result = await openEncryptedPhoto(
accountId: accountId,
chatId: chatId,
encrypted: encrypted,
cacheName: cacheName,
);
if (result.isOk) return EncryptedPhotoView.decrypted(result.file!);
return result.failure == CryptoFailure.unavailable
? const EncryptedPhotoView.locked()
: const EncryptedPhotoView.wrongKey();
}
Future<File?> _download(
String cacheName,
EncryptedPhotoUrlLoader urlLoader,
int maxBytes,
) async {
final url = await urlLoader();
if (url == null || url.isEmpty) return null;
MediaDownloadProgress.set(cacheName, 0);
try {
return await MediaCache.getOrDownload(
cacheName,
url,
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
maxBytes: maxBytes,
);
} finally {
MediaDownloadProgress.set(cacheName, null);
}
}
}
@@ -0,0 +1,185 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:komet_crypto/komet_crypto.dart';
import '../../backend/modules/messages.dart';
import '../storage/app_database.dart';
import '../storage/chat_encryption_store.dart';
import 'chat_crypto_service.dart';
import 'e2ee_service.dart';
// #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет
enum MessageDecryptionState { decrypted, wrongKey, unavailable }
@immutable
class MessageDecryption {
final String? plaintext;
final MessageDecryptionState state;
const MessageDecryption.decrypted(String this.plaintext)
: state = MessageDecryptionState.decrypted;
const MessageDecryption.wrongKey()
: plaintext = null,
state = MessageDecryptionState.wrongKey;
const MessageDecryption.unavailable()
: plaintext = null,
state = MessageDecryptionState.unavailable;
bool get isDecrypted => state == MessageDecryptionState.decrypted;
}
class MessageDecryptionCache {
MessageDecryptionCache._() {
ChatEncryptionStore.instance.revision.addListener(clear);
E2eeService.instance.revision.addListener(clear);
}
static final MessageDecryptionCache instance = MessageDecryptionCache._();
static const int _maxEntries = 1000;
final LinkedHashMap<String, ValueNotifier<MessageDecryption?>> _entries =
LinkedHashMap();
final Set<String> _inFlight = {};
ValueListenable<MessageDecryption?> listenableFor(String messageId) =>
_entryFor(messageId);
ValueNotifier<MessageDecryption?> _entryFor(String messageId) =>
_entries[messageId] ??= ValueNotifier<MessageDecryption?>(null);
void _evictStale(String keep) {
while (_entries.length > _maxEntries) {
final oldest = _entries.keys.first;
if (oldest == keep) break;
_entries.remove(oldest);
}
}
void seed(String messageId, String plaintext) {
_entryFor(messageId).value = MessageDecryption.decrypted(plaintext);
}
void adopt(String fromMessageId, String toMessageId) {
final value = _entries[fromMessageId]?.value;
if (value != null) _entryFor(toMessageId).value = value;
}
void request({
required int accountId,
required int chatId,
required String messageId,
required String cipherText,
}) {
if (cipherText.isEmpty) return;
final e2ee =
E2eeService.instance.available &&
KometCrypto.classifyText(cipherText) != TextClass.none;
if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) {
return;
}
if (_entryFor(messageId).value != null) return;
if (!_inFlight.add(messageId)) return;
_evictStale(messageId);
unawaited(
e2ee
? _resolveSealed(accountId, chatId, messageId, cipherText)
: _resolve(accountId, chatId, messageId, cipherText),
);
}
// #***! сквозное: открытый текст лежит в базе под локальным ключом
Future<void> _resolveSealed(
int accountId,
int chatId,
String messageId,
String cipherText,
) async {
try {
final row = await AppDatabase.loadMessage(accountId, chatId, messageId);
if (row == null) return;
final flag = row['e2ee'] as int? ?? 0;
final sealed = row['text_sealed'];
if (flag == CachedMessage.e2eeText && sealed is Uint8List) {
final text = await E2eeService.instance.openText(
accountId,
chatId,
sealed,
);
_entryFor(messageId).value = text == null
? const MessageDecryption.unavailable()
: MessageDecryption.decrypted(text);
return;
}
if (flag == CachedMessage.e2eeFailed) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
return;
}
if (flag == CachedMessage.e2eeFile) {
_entryFor(messageId).value = const MessageDecryption.decrypted('');
return;
}
if (flag != CachedMessage.e2eeNone || !E2eeService.instance.available) {
return;
}
switch (KometCrypto.classifyText(cipherText)) {
case TextClass.session:
_entryFor(messageId).value = const MessageDecryption.unavailable();
case TextClass.offer:
case TextClass.answer:
_entryFor(messageId).value = MessageDecryption.decrypted(
cipherText.split('\n').first,
);
case TextClass.legacy:
if (ChatCryptoService.instance.isEnabled(accountId, chatId)) {
_inFlight.remove(messageId);
await _resolve(accountId, chatId, messageId, cipherText);
}
case TextClass.none:
break;
}
} finally {
_inFlight.remove(messageId);
}
}
Future<void> _resolve(
int accountId,
int chatId,
String messageId,
String cipherText,
) async {
try {
final crypto = ChatCryptoService.instance;
final result = await crypto.decrypt(accountId, chatId, cipherText);
if (result.isOk) {
_entryFor(messageId).value = MessageDecryption.decrypted(result.text!);
return;
}
switch (result.failure) {
case CryptoFailure.wrongKey:
_entryFor(messageId).value = const MessageDecryption.wrongKey();
case CryptoFailure.noKey:
if (crypto.looksEncrypted(cipherText)) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
}
case CryptoFailure.notEncrypted:
case CryptoFailure.malformed:
case CryptoFailure.unavailable:
case null:
break;
}
} finally {
_inFlight.remove(messageId);
}
}
void clear() {
_entries.clear();
_inFlight.clear();
}
}
+205
View File
@@ -0,0 +1,205 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
// #***! шифрованное фото едет как валидный RGB8 PNG из шума, пиксели = байты блоба
const int _channels = 3;
const int _maxDimension = 16384;
// #***! zlib раздувает без предела, распаковываем порциями по бюджету из IHDR
const int _inflateChunk = 1 << 16;
const List<int> _signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
final Uint32List _crcTable = _buildCrcTable();
Uint32List _buildCrcTable() {
final table = Uint32List(256);
for (var n = 0; n < 256; n++) {
var c = n;
for (var k = 0; k < 8; k++) {
c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
}
table[n] = c;
}
return table;
}
int _crc32(List<int> bytes) {
var crc = 0xFFFFFFFF;
for (final byte in bytes) {
crc = _crcTable[(crc ^ byte) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFF;
}
void _writeChunk(BytesBuilder out, String type, List<int> data) {
final typeBytes = type.codeUnits;
out.add(_be32(data.length));
out.add(typeBytes);
out.add(data);
out.add(_be32(_crc32([...typeBytes, ...data])));
}
Uint8List _be32(int value) => Uint8List(4)
..[0] = (value >> 24) & 0xFF
..[1] = (value >> 16) & 0xFF
..[2] = (value >> 8) & 0xFF
..[3] = value & 0xFF;
int _readBe32(Uint8List bytes, int offset) =>
(bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
Uint8List? wrapNoisePng(Uint8List blob, {Random? random}) {
final rng = random ?? Random.secure();
final pixels = (blob.length + _channels - 1) ~/ _channels;
final width = max(1, sqrt(pixels).ceil());
if (width > _maxDimension) return null;
final height = max(1, (pixels + width - 1) ~/ width);
final stride = width * _channels;
final raw = Uint8List((stride + 1) * height);
var source = 0;
for (var row = 0; row < height; row++) {
final start = row * (stride + 1) + 1;
for (var column = 0; column < stride; column++) {
raw[start + column] = source < blob.length
? blob[source]
: rng.nextInt(256);
source++;
}
}
final out = BytesBuilder(copy: false);
out.add(_signature);
_writeChunk(out, 'IHDR', [..._be32(width), ..._be32(height), 8, 2, 0, 0, 0]);
_writeChunk(out, 'IDAT', ZLibEncoder(level: 1).convert(raw));
_writeChunk(out, 'IEND', const []);
return out.toBytes();
}
// #***! обратно из PNG в байты, null если это не наш RGB8 без interlace
Uint8List? unwrapNoisePng(Uint8List png) {
if (png.length < _signature.length + 8) return null;
for (var i = 0; i < _signature.length; i++) {
if (png[i] != _signature[i]) return null;
}
var offset = _signature.length;
int? width;
int? height;
final idat = BytesBuilder(copy: false);
while (offset + 8 <= png.length) {
final length = _readBe32(png, offset);
final type = String.fromCharCodes(png, offset + 4, offset + 8);
final dataStart = offset + 8;
final dataEnd = dataStart + length;
if (length < 0 || dataEnd + 4 > png.length) return null;
if (type == 'IHDR') {
if (length != 13) return null;
width = _readBe32(png, dataStart);
height = _readBe32(png, dataStart + 4);
final depth = png[dataStart + 8];
final color = png[dataStart + 9];
final interlace = png[dataStart + 12];
if (depth != 8 || color != 2 || interlace != 0) return null;
if (width <= 0 || height <= 0) return null;
if (width > _maxDimension || height > _maxDimension) return null;
} else if (type == 'IDAT') {
idat.add(Uint8List.sublistView(png, dataStart, dataEnd));
} else if (type == 'IEND') {
break;
}
offset = dataEnd + 4;
}
if (width == null || height == null) return null;
final stride = width * _channels;
final expected = (stride + 1) * height;
final Uint8List? inflated;
try {
inflated = _inflateBounded(idat.toBytes(), expected);
} catch (_) {
return null;
}
if (inflated == null) return null;
final out = Uint8List(stride * height);
final previous = Uint8List(stride);
final current = Uint8List(stride);
for (var row = 0; row < height; row++) {
final start = row * (stride + 1);
final filter = inflated[start];
current.setRange(0, stride, inflated, start + 1);
for (var i = 0; i < stride; i++) {
final left = i >= _channels ? current[i - _channels] : 0;
final up = previous[i];
final upLeft = i >= _channels ? previous[i - _channels] : 0;
final int predictor;
switch (filter) {
case 0:
predictor = 0;
case 1:
predictor = left;
case 2:
predictor = up;
case 3:
predictor = (left + up) >> 1;
case 4:
predictor = _paeth(left, up, upLeft);
default:
return null;
}
current[i] = (current[i] + predictor) & 0xFF;
}
out.setRange(row * stride, (row + 1) * stride, current);
previous.setAll(0, current);
}
return out;
}
// #***! ровно обещанный объём, больше заголовка нам всё равно не нужно
Uint8List? _inflateBounded(Uint8List input, int limit) {
final out = BytesBuilder(copy: false);
final sink = _BoundedSink(out, limit);
final converter = ZLibDecoder().startChunkedConversion(sink);
try {
for (var offset = 0; offset < input.length; offset += _inflateChunk) {
final end = offset + _inflateChunk < input.length
? offset + _inflateChunk
: input.length;
converter.add(Uint8List.sublistView(input, offset, end));
if (sink.full) break;
}
if (!sink.full) converter.close();
} catch (_) {
return null;
}
final bytes = out.toBytes();
return bytes.length < limit ? null : Uint8List.sublistView(bytes, 0, limit);
}
class _BoundedSink implements Sink<List<int>> {
_BoundedSink(this._out, this._limit);
final BytesBuilder _out;
final int _limit;
bool get full => _out.length >= _limit;
@override
void add(List<int> data) {
if (full) return;
final room = _limit - _out.length;
_out.add(data.length <= room ? data : data.sublist(0, room));
}
@override
void close() {}
}
int _paeth(int a, int b, int c) {
final p = a + b - c;
final pa = (p - a).abs();
final pb = (p - b).abs();
final pc = (p - c).abs();
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
+222
View File
@@ -0,0 +1,222 @@
enum CheckersSide { white, black }
class Checkers {
static const int size = 8;
static const int empty = 0;
static const int whiteMan = 1;
static const int whiteKing = 2;
static const int blackMan = 3;
static const int blackKing = 4;
static const List<List<int>> _dirs = [
[-1, -1],
[-1, 1],
[1, -1],
[1, 1],
];
static List<int> initial() {
final board = List<int>.filled(size * size, empty);
for (var r = 0; r < size; r++) {
for (var c = 0; c < size; c++) {
if ((r + c) % 2 == 0) continue;
final i = r * size + c;
if (r <= 2) board[i] = blackMan;
if (r >= 5) board[i] = whiteMan;
}
}
return board;
}
static CheckersSide? sideOf(int piece) {
if (piece == whiteMan || piece == whiteKing) return CheckersSide.white;
if (piece == blackMan || piece == blackKing) return CheckersSide.black;
return null;
}
static bool isKing(int piece) => piece == whiteKing || piece == blackKing;
static CheckersSide opponent(CheckersSide side) =>
side == CheckersSide.white ? CheckersSide.black : CheckersSide.white;
static int _row(int i) => i ~/ size;
static int _col(int i) => i % size;
static bool _inB(int r, int c) => r >= 0 && r < size && c >= 0 && c < size;
static int _idx(int r, int c) => r * size + c;
static int _lastRow(CheckersSide side) =>
side == CheckersSide.white ? 0 : size - 1;
static int _kingOf(CheckersSide side) =>
side == CheckersSide.white ? whiteKing : blackKing;
static List<List<int>> legalMoves(List<int> board, CheckersSide side) {
final captures = <List<int>>[];
for (var i = 0; i < board.length; i++) {
if (sideOf(board[i]) != side) continue;
_collectCaptures(List<int>.of(board), i, side, [i], <int>{}, captures);
}
if (captures.isNotEmpty) return captures;
final quiet = <List<int>>[];
for (var i = 0; i < board.length; i++) {
if (sideOf(board[i]) != side) continue;
_collectQuiet(board, i, side, quiet);
}
return quiet;
}
static void _collectCaptures(
List<int> work,
int at,
CheckersSide side,
List<int> path,
Set<int> captured,
List<List<int>> out,
) {
final steps = _captureSteps(work, at, captured);
if (steps.isEmpty) {
if (path.length > 1) out.add(List<int>.of(path));
return;
}
final piece = work[at];
for (final step in steps) {
final landing = step[0];
final victim = step[1];
final promote = !isKing(piece) && _row(landing) == _lastRow(side);
final moved = promote ? _kingOf(side) : piece;
work[at] = empty;
work[landing] = moved;
captured.add(victim);
path.add(landing);
_collectCaptures(work, landing, side, path, captured, out);
path.removeLast();
captured.remove(victim);
work[landing] = empty;
work[at] = piece;
}
}
static List<List<int>> _captureSteps(
List<int> work,
int at,
Set<int> captured,
) {
final piece = work[at];
final side = sideOf(piece);
if (side == null) return const [];
final king = isKing(piece);
final r0 = _row(at);
final c0 = _col(at);
final result = <List<int>>[];
for (final d in _dirs) {
var r = r0 + d[0];
var c = c0 + d[1];
if (king) {
while (_inB(r, c) && work[_idx(r, c)] == empty) {
r += d[0];
c += d[1];
}
if (!_inB(r, c)) continue;
final vi = _idx(r, c);
if (sideOf(work[vi]) == side || captured.contains(vi)) continue;
var lr = r + d[0];
var lc = c + d[1];
while (_inB(lr, lc) && work[_idx(lr, lc)] == empty) {
result.add([_idx(lr, lc), vi]);
lr += d[0];
lc += d[1];
}
} else {
if (!_inB(r, c)) continue;
final vi = _idx(r, c);
if (work[vi] == empty ||
sideOf(work[vi]) == side ||
captured.contains(vi)) {
continue;
}
final lr = r + d[0];
final lc = c + d[1];
if (_inB(lr, lc) && work[_idx(lr, lc)] == empty) {
result.add([_idx(lr, lc), vi]);
}
}
}
return result;
}
static void _collectQuiet(
List<int> board,
int at,
CheckersSide side,
List<List<int>> out,
) {
final piece = board[at];
final r0 = _row(at);
final c0 = _col(at);
if (isKing(piece)) {
for (final d in _dirs) {
var r = r0 + d[0];
var c = c0 + d[1];
while (_inB(r, c) && board[_idx(r, c)] == empty) {
out.add([at, _idx(r, c)]);
r += d[0];
c += d[1];
}
}
} else {
final forward = side == CheckersSide.white ? -1 : 1;
for (final dc in const [-1, 1]) {
final r = r0 + forward;
final c = c0 + dc;
if (_inB(r, c) && board[_idx(r, c)] == empty) {
out.add([at, _idx(r, c)]);
}
}
}
}
static List<int> applyMove(List<int> board, List<int> path) {
final next = List<int>.of(board);
if (path.length < 2) return next;
final from = path.first;
final side = sideOf(board[from]);
if (side == null) return next;
final piece = board[from];
next[from] = empty;
var promoted = isKing(piece);
for (var k = 0; k < path.length - 1; k++) {
final a = path[k];
final b = path[k + 1];
final dr = (_row(b) - _row(a)).sign;
final dc = (_col(b) - _col(a)).sign;
var r = _row(a) + dr;
var c = _col(a) + dc;
while (r != _row(b) || c != _col(b)) {
final vi = _idx(r, c);
if (next[vi] != empty && sideOf(next[vi]) != side) {
next[vi] = empty;
}
r += dr;
c += dc;
}
if (_row(b) == _lastRow(side)) promoted = true;
}
next[path.last] = promoted ? _kingOf(side) : piece;
return next;
}
static bool _hasPieces(List<int> board, CheckersSide side) =>
board.any((p) => sideOf(p) == side);
static CheckersSide? winner(List<int> board, CheckersSide toMove) {
if (!_hasPieces(board, toMove) || legalMoves(board, toMove).isEmpty) {
return opponent(toMove);
}
return null;
}
}
+262
View File
@@ -0,0 +1,262 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:app_links/app_links.dart';
import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart';
import '../../backend/api.dart';
import '../../frontend/debug/log_export.dart';
import '../../frontend/screens/digital_id/digital_id_web_screen.dart';
import '../../frontend/widgets/custom_notification.dart';
import '../../frontend/widgets/max_link_handler.dart';
import '../../frontend/widgets/swipe_route.dart';
import '../../main.dart';
import '../webpush/max_web_socket.dart';
import '../webpush/web_push_service.dart';
import 'desktop_url_scheme.dart';
import 'max_link.dart';
class DeepLinkService {
DeepLinkService._();
static final DeepLinkService instance = DeepLinkService._();
final AppLinks _appLinks = AppLinks();
StreamSubscription<Uri>? _sub;
StreamSubscription<SessionState>? _stateSub;
String? _pending;
bool _pendingLogExport = false;
String? _pendingExternalCallback;
WebPushSubscription? _pendingWebPush;
String? _lastExternalCallback;
Timer? _externalCallbackRetry;
Timer? _webPushRetry;
Timer? _logExportRetry;
bool _ready = false;
bool _started = false;
Future<void> init() async {
if (_started) return;
_started = true;
await DesktopUrlScheme.register();
_stateSub = api.stateStream.listen((state) {
if (state == SessionState.online) _flushPending();
});
_sub = _appLinks.uriLinkStream.listen(_onUri);
try {
final initial = await _appLinks.getInitialLink();
if (initial != null) _onUri(initial);
} catch (_) {}
}
void markReady() {
_ready = true;
_flushPending();
}
void handle(Uri uri) => _onUri(uri);
void _onUri(Uri uri) {
if (_isLogExportLink(uri)) {
_pendingLogExport = true;
_flushPending();
return;
}
final webPush = _parseWebPushLink(uri);
if (webPush != null) {
_pendingWebPush = webPush;
_flushPending();
return;
}
if (_isExternalCallback(uri)) {
final callbackUrl = uri.toString();
if (callbackUrl == _lastExternalCallback) return;
_lastExternalCallback = callbackUrl;
_pendingExternalCallback = callbackUrl;
_flushPending();
return;
}
final url = _normalize(uri);
if (url == null) return;
_pending = url;
_flushPending();
}
void _flushPending() {
final context = QlyraApp.navigatorKey.currentContext;
if (_pendingLogExport) {
if (context == null) {
_logExportRetry ??= Timer(const Duration(milliseconds: 300), () {
_logExportRetry = null;
_flushPending();
});
} else {
_pendingLogExport = false;
exportDebugLog(context);
}
}
if (_pendingExternalCallback != null) {
if (context == null || api.state != SessionState.online) {
_externalCallbackRetry ??= Timer(const Duration(milliseconds: 300), () {
_externalCallbackRetry = null;
_flushPending();
});
} else {
final url = _pendingExternalCallback!;
_pendingExternalCallback = null;
_handleExternalCallback(context, url);
}
}
if (_pendingWebPush != null) {
if (context == null) {
_webPushRetry ??= Timer(const Duration(milliseconds: 300), () {
_webPushRetry = null;
_flushPending();
});
} else {
final subscription = _pendingWebPush!;
_pendingWebPush = null;
_handleWebPush(context, subscription);
}
}
if (!_ready || context == null) return;
final pending = _pending;
if (pending == null) return;
final needsConnection = MaxLink.parse(pending)?.needsConnection ?? true;
if (needsConnection && api.state != SessionState.online) return;
_pending = null;
tryHandleMaxLink(context, pending);
}
bool _isExternalCallback(Uri uri) {
final scheme = uri.scheme.toLowerCase();
if (scheme != 'https' && scheme != 'http' && scheme != 'ikar') return false;
final host = uri.host.toLowerCase();
if (host != 'ikar.kusoft.xyz' && host != 'ikar.kusoft.xyz') return false;
return uri.queryParameters['externalCallback'] == '1';
}
Future<void> _handleExternalCallback(BuildContext context, String url) async {
try {
final launch = await webAppModule.handleExternalCallback(url);
if (!context.mounted) return;
await pushSwipeable(
context,
(_) => DigitalIdWebScreen(initialLaunch: launch),
);
} catch (e) {
if (context.mounted) {
showCustomNotification(context, 'Не удалось завершить Цифровой ID: $e');
}
}
}
WebPushSubscription? _parseWebPushLink(Uri uri) {
if (!Platform.isIOS) return null;
final scheme = uri.scheme.toLowerCase();
if (scheme != 'ikar') return null;
final segments = <String>[
if (uri.host.isNotEmpty) uri.host,
...uri.pathSegments,
].where((s) => s.isNotEmpty).toList();
if (segments.length != 1 || segments.first != 'webpush') return null;
final endpoint = uri.queryParameters['endpoint'] ?? '';
final publicKey = uri.queryParameters['p256dh'] ?? '';
final authKey = uri.queryParameters['auth'] ?? '';
if (endpoint.isEmpty || publicKey.isEmpty || authKey.isEmpty) return null;
if (Uri.tryParse(endpoint)?.isScheme('https') != true) return null;
return WebPushSubscription(
endpoint: endpoint,
publicKey: publicKey,
authKey: authKey,
);
}
Future<void> _handleWebPush(
BuildContext context,
WebPushSubscription subscription,
) async {
final l10n = AppLocalizations.of(context)!;
if (!await WebPushService.instance.isAuthorized()) {
if (context.mounted) {
showCustomNotification(context, l10n.webPushNotAuthorized);
}
return;
}
try {
await WebPushService.instance.registerSubscription(subscription);
if (context.mounted) {
showCustomNotification(context, l10n.webPushLinked);
}
} on MaxWebException catch (e) {
if (context.mounted) {
showCustomNotification(context, l10n.webPushLinkFailed(e.message));
}
} catch (e) {
if (context.mounted) {
showCustomNotification(context, l10n.webPushLinkFailed('$e'));
}
}
}
bool _isLogExportLink(Uri uri) {
final scheme = uri.scheme.toLowerCase();
final segments = <String>[
if (scheme == 'ikar' && uri.host.isNotEmpty) uri.host,
...uri.pathSegments,
].where((s) => s.isNotEmpty).toList();
if (scheme == 'ikar') {
return segments.length == 1 && segments.first == 'export-logs';
}
return false;
}
String? _normalize(Uri uri) {
final scheme = uri.scheme.toLowerCase();
if (scheme == 'https' || scheme == 'http') {
final host = uri.host.toLowerCase();
if (host == 'ikar.kusoft.xyz' || host == 'ikar.kusoft.xyz') return uri.toString();
return null;
}
if (scheme == 'ikar' || scheme == 'max') {
final segments = <String>[
if (uri.host.isNotEmpty && uri.host.toLowerCase() != 'ikar.kusoft.xyz') uri.host,
...uri.pathSegments,
].where((s) => s.isNotEmpty).toList();
if (segments.isEmpty) return null;
final query = uri.query.isNotEmpty ? '?${uri.query}' : '';
return 'https://ikar.kusoft.xyz/${segments.join('/')}$query';
}
return null;
}
void dispose() {
_logExportRetry?.cancel();
_logExportRetry = null;
_webPushRetry?.cancel();
_webPushRetry = null;
_sub?.cancel();
_sub = null;
_stateSub?.cancel();
_stateSub = null;
_started = false;
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:io';
import '../utils/logger.dart';
const List<String> _schemes = ['ikar', 'max'];
abstract class DesktopUrlScheme {
static Future<void> register() async {
try {
if (Platform.isWindows) {
await _registerWindows();
} else if (Platform.isLinux) {
await _registerLinux();
}
} catch (e) {
logger.w('DesktopUrlScheme: регистрация не удалась: $e');
}
}
static Future<void> _registerWindows() async {
final exe = Platform.resolvedExecutable;
for (final scheme in _schemes) {
final capitalized = '${scheme[0].toUpperCase()}${scheme.substring(1)}';
final base = 'HKCU\\Software\\Classes\\$scheme';
await _run('reg', ['add', base, '/ve', '/d', 'URL:$capitalized', '/f']);
await _run('reg', ['add', base, '/v', 'URL Protocol', '/d', '', '/f']);
await _run('reg', [
'add',
'$base\\shell\\open\\command',
'/ve',
'/d',
'"$exe" "%1"',
'/f',
]);
}
}
static Future<void> _registerLinux() async {
final home = Platform.environment['HOME'];
if (home == null || home.isEmpty) return;
final exe = Platform.resolvedExecutable;
final appsDir = Directory('$home/.local/share/applications');
await appsDir.create(recursive: true);
const fileName = 'qlyra-url-handler.desktop';
final mimeTypes = _schemes.map((s) => 'x-scheme-handler/$s').join(';');
final desktop =
'[Desktop Entry]\n'
'Type=Application\n'
'Name=ИКАР\n'
'Exec="$exe" %u\n'
'Terminal=false\n'
'NoDisplay=true\n'
'MimeType=$mimeTypes;\n';
final file = File('${appsDir.path}/$fileName');
if (!file.existsSync() || await file.readAsString() != desktop) {
await file.writeAsString(desktop);
}
for (final scheme in _schemes) {
await _run('xdg-mime', ['default', fileName, 'x-scheme-handler/$scheme']);
}
await _run('update-desktop-database', [appsDir.path]);
}
static Future<void> _run(String executable, List<String> args) async {
try {
await Process.run(executable, args);
} catch (_) {}
}
}
+299
View File
@@ -0,0 +1,299 @@
import 'message_link_token.dart';
enum MaxContentKind { public, invite, user, content }
sealed class MaxLink {
const MaxLink();
bool get needsConnection => false;
static final RegExp _schemeless = RegExp(
r'^(?:www\.)?ikar\.kusoft\.xyz(?:[/?#]|$)',
caseSensitive: false,
);
static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$');
static const Set<String> _reserved = {
'login',
'ps',
'tos',
'privacy',
'about',
'help',
};
static bool isMaxLink(String url) => parse(url) != null;
static MaxLink? parse(String input) {
final uri = _canonical(input);
if (uri == null) return null;
final segments = uri.pathSegments
.where((s) => s.isNotEmpty)
.toList(growable: false);
final params = uri.queryParameters;
final url = uri.replace(scheme: 'https', host: 'ikar.kusoft.xyz').toString();
if (segments.isEmpty) return _parseQueryOnly(params);
if (segments.first.startsWith(':')) {
return _parseRoute(segments, params, url);
}
return _parseContent(segments, params, url);
}
static Uri? _canonical(String input) {
var value = input.trim();
if (value.isEmpty) return null;
if (!value.contains('://')) {
if (!_schemeless.hasMatch(value)) return null;
value = 'https://$value';
}
final uri = Uri.tryParse(value);
if (uri == null) return null;
final scheme = uri.scheme.toLowerCase();
if (scheme != 'https' && scheme != 'http' && scheme != 'max') return null;
final host = uri.host.toLowerCase();
if (host != 'ikar.kusoft.xyz' && host != 'ikar.kusoft.xyz') return null;
return uri;
}
static MaxLink _parseQueryOnly(Map<String, String> params) {
final userId = _idParam(params, 'uid');
if (userId != null) return MaxContactIdLink(userId);
final chatId = _idParam(params, 'cid');
if (chatId != null) return MaxChatIdLink(chatId);
return const MaxRootLink();
}
static MaxLink _parseRoute(
List<String> segments,
Map<String, String> params,
String url,
) {
final route = segments.join('/').toLowerCase();
switch (route) {
case ':auth':
return MaxAuthLink(url);
case ':current':
return const MaxCurrentLink();
case ':share-self-out':
return const MaxShareSelfLink();
case ':share':
return MaxShareTextLink(params['text']?.trim() ?? '');
case ':folder':
final id = params['id']?.trim();
if (id != null && id.isNotEmpty) return MaxFolderLink(id);
}
if (segments.length > 1 && segments.first.toLowerCase() == ':auth') {
return MaxAuthLink(url);
}
return MaxRouteLink(route, params);
}
static MaxLink? _parseContent(
List<String> segments,
Map<String, String> params,
String url,
) {
final first = segments.first;
final lower = first.toLowerCase();
if (segments.length == 1) {
final name = _publicName(first);
if (name == null) return null;
final startApp = params['startapp'] ?? params['startApp'];
if (startApp != null && startApp.isNotEmpty) {
return MaxWebAppLink('https://ikar.kusoft.xyz/$name', startApp.split('&').first);
}
return MaxContentLink(
kind: MaxContentKind.public,
url: url,
baseUrl: 'https://ikar.kusoft.xyz/$name',
startPayload: _startPayload(params),
);
}
if (segments.length == 2) {
switch (lower) {
case 'stickerset':
return MaxStickerSetLink('$lower/${segments[1]}');
case 'joincall':
return MaxCallLink(url);
case 'join':
return MaxContentLink(
kind: MaxContentKind.invite,
url: url,
baseUrl: url,
);
case 'u':
return MaxContentLink(
kind: MaxContentKind.user,
url: url,
baseUrl: url,
);
}
final messageId = _messageId(segments[1]);
final name = _publicName(first);
if (messageId == null || name == null) return null;
return MaxContentLink(
kind: MaxContentKind.public,
url: url,
baseUrl: 'https://ikar.kusoft.xyz/$name',
messageId: messageId,
lookup: '$name/${segments[1]}',
);
}
if (lower == 'c' && segments.length == 3) {
final chatId = int.tryParse(segments[1]);
final messageId = _messageId(segments[2]);
if (chatId == null || messageId == null) return null;
return MaxContentLink(
kind: MaxContentKind.content,
url: url,
baseUrl: 'https://ikar.kusoft.xyz/c/$chatId',
messageId: messageId,
lookup: '/c/$chatId/${segments[2]}',
);
}
if (lower == 'join') {
return MaxContentLink(
kind: MaxContentKind.invite,
url: url,
baseUrl: url,
);
}
return null;
}
static String? _publicName(String segment) {
final name = segment.startsWith('@') ? segment.substring(1) : segment;
if (name.isEmpty) return null;
if (_reserved.contains(name.toLowerCase())) return null;
if (!_segment.hasMatch(name)) return null;
return name;
}
static int? _messageId(String segment) =>
int.tryParse(segment) ??
int.tryParse(MessageLinkToken.decode(segment) ?? '');
static String? _startPayload(Map<String, String> params) {
final value = params['start']?.trim();
return (value == null || value.isEmpty) ? null : value;
}
static int? _idParam(Map<String, String> params, String key) {
final raw = params[key]?.trim();
if (raw == null || raw.isEmpty) return null;
final value = int.tryParse(raw);
return (value == null || value <= 0) ? null : value;
}
}
class MaxRootLink extends MaxLink {
const MaxRootLink();
}
class MaxCurrentLink extends MaxLink {
const MaxCurrentLink();
}
class MaxShareSelfLink extends MaxLink {
const MaxShareSelfLink();
}
class MaxAuthLink extends MaxLink {
final String url;
const MaxAuthLink(this.url);
@override
bool get needsConnection => true;
}
class MaxCallLink extends MaxLink {
final String url;
const MaxCallLink(this.url);
@override
bool get needsConnection => true;
}
class MaxStickerSetLink extends MaxLink {
final String path;
const MaxStickerSetLink(this.path);
@override
bool get needsConnection => true;
}
class MaxShareTextLink extends MaxLink {
final String text;
const MaxShareTextLink(this.text);
}
class MaxFolderLink extends MaxLink {
final String folderId;
const MaxFolderLink(this.folderId);
}
class MaxRouteLink extends MaxLink {
final String route;
final Map<String, String> params;
const MaxRouteLink(this.route, this.params);
}
class MaxContactIdLink extends MaxLink {
final int userId;
const MaxContactIdLink(this.userId);
@override
bool get needsConnection => true;
}
class MaxChatIdLink extends MaxLink {
final int chatId;
final int? messageId;
const MaxChatIdLink(this.chatId, {this.messageId});
}
class MaxWebAppLink extends MaxLink {
final String url;
final String startApp;
const MaxWebAppLink(this.url, this.startApp);
@override
bool get needsConnection => true;
}
class MaxContentLink extends MaxLink {
final MaxContentKind kind;
final String url;
final String baseUrl;
final String? startPayload;
final int? messageId;
final String lookup;
const MaxContentLink({
required this.kind,
required this.url,
required this.baseUrl,
this.startPayload,
this.messageId,
String? lookup,
}) : lookup = lookup ?? url;
@override
bool get needsConnection => true;
}
+49
View File
@@ -0,0 +1,49 @@
import 'dart:convert';
abstract final class MessageLinkToken {
static final RegExp _token = RegExp(r'^[A-Za-z0-9_-]{11}$');
static final BigInt _byteMask = BigInt.from(0xff);
static final BigInt _maxId = (BigInt.one << 64) - BigInt.one;
static String? encode(String messageId) {
final parsed = BigInt.tryParse(messageId);
if (parsed == null || parsed <= BigInt.zero || parsed > _maxId) return null;
var value = parsed;
final bytes = List<int>.filled(8, 0);
for (var i = 7; i >= 0; i--) {
bytes[i] = (value & _byteMask).toInt();
value = value >> 8;
}
return base64Url.encode(bytes).replaceAll('=', '');
}
static String? decode(String token) {
if (!_token.hasMatch(token)) return null;
final List<int> bytes;
try {
bytes = base64Url.decode('$token=');
} on FormatException {
return null;
}
var value = BigInt.zero;
for (final byte in bytes) {
value = (value << 8) | BigInt.from(byte);
}
return value == BigInt.zero ? null : value.toString();
}
static String? messageUrl({
required int chatId,
required String messageId,
String? publicLink,
}) {
final token = encode(messageId);
if (token == null) return null;
final base = publicLink?.trim() ?? '';
if (base.isEmpty) return 'https://ikar.kusoft.xyz/c/$chatId/$token';
final trimmed = base.endsWith('/')
? base.substring(0, base.length - 1)
: base;
return '$trimmed/$token';
}
}
+35
View File
@@ -0,0 +1,35 @@
import '../cache/info_cache.dart';
import '../storage/app_database.dart';
const String _host = 'https://ikar.kusoft.xyz';
// #***! ссылка на профиль из имени или готового адреса
String? profileLinkOf(String? rawLink) {
final link = rawLink?.trim();
if (link == null || link.isEmpty) return null;
if (!link.contains('://')) {
final name = link.startsWith('@') ? link.substring(1) : link;
return name.isEmpty ? null : '$_host/$name';
}
final path = Uri.tryParse(link)?.path.replaceAll('/', '') ?? '';
return path.isEmpty ? null : link;
}
// #***! своя ссылка, сначала конфиг потом кэш потом сервер
Future<String?> ownProfileLink() async {
final profile = await AppDatabase.loadActiveProfile();
final id = profile?.id ?? 0;
if (id == 0) return null;
final invite = profileLinkOf(
await AppDatabase.getSyncValue(id, SyncKey.profileInviteLink),
);
if (invite != null) return invite;
final cached = await ContactInfoFetch.get(id);
final short = profileLinkOf(cached?.raw['link'] as String?);
if (short != null) return short;
final fresh = await ContactInfoFetch.get(id, forceRefresh: true);
return profileLinkOf(fresh?.raw['link'] as String?);
}
+22
View File
@@ -0,0 +1,22 @@
// #***! аудиофайл в плеере плюс откуда он взялся
class AudioFileTrack {
const AudioFileTrack({
required this.cacheName,
required this.path,
required this.name,
required this.sourceName,
this.chatId,
this.messageId,
this.messageTime,
this.thumbnailUrl,
});
final String cacheName;
final String path;
final String name;
final String sourceName;
final int? chatId;
final String? messageId;
final int? messageTime;
final String? thumbnailUrl;
}
@@ -0,0 +1,156 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
import '../calls/active_call.dart';
import 'audio_file_track.dart';
import 'background_audio_handler.dart';
import 'desktop_audio_backend.dart';
// #***! плеер музыки с управлением из шторки
class AudioPlaybackController {
AudioPlaybackController._(this._handler) {
_subscriptions.add(
_handler.playbackState.listen((state) {
playing.value = state.playing;
processingState.value = state.processingState;
bufferedPosition.value = state.bufferedPosition;
}),
);
_subscriptions.add(
_handler.mediaItem.listen((item) {
duration.value = item?.duration ?? Duration.zero;
}),
);
_subscriptions.add(
_handler.positionStream.listen((value) => position.value = value),
);
_subscriptions.add(
_handler.bufferedPositionStream.listen(
(value) => bufferedPosition.value = value,
),
);
_subscriptions.add(
_handler.errors.stream.listen((value) => error.value = value),
);
ActiveCall.instance.current.addListener(_onActiveCallChanged);
}
// #***! синглтон с ленивой инициализацией, audio_service поднимать дорого
static AudioPlaybackController? _instance;
static Future<AudioPlaybackController>? _pending;
static AudioPlaybackController get instance {
final value = _instance;
if (value == null) throw StateError('Audio playback is not initialized');
return value;
}
static bool get isInitialized => _instance != null;
static final ValueNotifier<String?> error = ValueNotifier(null);
static Future<AudioPlaybackController> ensureInitialized(
String notificationChannelName,
) async {
final ready = _instance;
if (ready != null) return ready;
final pending = _pending ??= _create(notificationChannelName);
try {
return await pending;
} catch (_) {
_pending = null;
rethrow;
}
}
static Future<AudioPlaybackController> _create(String channelName) async {
DesktopAudioBackend.ensureRegistered();
final handler = switch (defaultTargetPlatform) {
TargetPlatform.windows ||
TargetPlatform.linux => BackgroundAudioHandler(),
_ => await AudioService.init(
builder: BackgroundAudioHandler.new,
config: AudioServiceConfig(
androidNotificationChannelId: 'qlyra.audio.playback',
androidNotificationChannelName: channelName,
androidNotificationOngoing: false,
androidStopForegroundOnPause: true,
),
),
};
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
return _instance = AudioPlaybackController._(handler);
}
final BackgroundAudioHandler _handler;
final List<StreamSubscription<dynamic>> _subscriptions = [];
bool _pausedByCall = false;
// #***! состояние отдельными notifier, плашки плеера подписаны
final ValueNotifier<bool> playing = ValueNotifier(false);
final ValueNotifier<Duration> position = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> bufferedPosition = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> duration = ValueNotifier(Duration.zero);
final ValueNotifier<AudioProcessingState> processingState = ValueNotifier(
AudioProcessingState.idle,
);
// #***! запуск трека
Future<void> playTrack(AudioFileTrack track) async {
error.value = null;
_pausedByCall = false;
position.value = Duration.zero;
bufferedPosition.value = Duration.zero;
duration.value = Duration.zero;
await _handler.load(track);
await _handler.play();
}
Future<void> toggle() {
_pausedByCall = false;
return playing.value ? _handler.pause() : _handler.play();
}
Future<void> seek(Duration value) => _handler.seek(value);
Future<void> stop() {
_pausedByCall = false;
return _handler.stop();
}
Future<void> dispose() async {
ActiveCall.instance.current.removeListener(_onActiveCallChanged);
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
await _handler.dispose();
playing.dispose();
position.dispose();
bufferedPosition.dispose();
duration.dispose();
processingState.dispose();
if (identical(_instance, this)) {
_instance = null;
_pending = null;
}
}
// #***! на звонок ставим на паузу потом возвращаем
void _onActiveCallChanged() {
if (ActiveCall.instance.current.value != null) {
if (!playing.value) return;
_pausedByCall = true;
unawaited(_handler.pause());
return;
}
if (!_pausedByCall) return;
_pausedByCall = false;
if (processingState.value == AudioProcessingState.idle) return;
unawaited(_handler.play());
}
}
@@ -0,0 +1,118 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import 'audio_file_track.dart';
// #***! обвязка audio_service, рисует уведомление и ловит кнопки наушников
class BackgroundAudioHandler extends BaseAudioHandler with SeekHandler {
BackgroundAudioHandler() {
_subscriptions.add(
_player.playbackEventStream.listen((_) => _broadcastState()),
);
_subscriptions.add(
_player.errorStream.listen((error) => errors.add(error.message ?? '')),
);
_subscriptions.add(_player.durationStream.listen(_updateDuration));
}
final AudioPlayer _player = AudioPlayer();
final StreamController<String> errors = StreamController.broadcast();
final List<StreamSubscription<dynamic>> _subscriptions = [];
Stream<Duration> get positionStream => _player.positionStream;
Stream<Duration> get bufferedPositionStream => _player.bufferedPositionStream;
// #***! загрузка трека с метаданными для шторки
Future<void> load(AudioFileTrack track) async {
final item = MediaItem(
id: track.cacheName,
title: track.name,
album: track.sourceName.isEmpty ? null : track.sourceName,
artUri: _artUri(track.thumbnailUrl),
extras: {
'path': track.path,
'chatId': track.chatId,
'messageId': track.messageId,
'messageTime': track.messageTime,
},
);
mediaItem.add(item);
final duration = await _player.setFilePath(track.path);
if (duration != null) mediaItem.add(item.copyWith(duration: duration));
_broadcastState();
}
Uri? _artUri(String? value) {
if (value == null || value.isEmpty) return null;
final uri = Uri.tryParse(value);
if (uri == null || !const {'http', 'https', 'file'}.contains(uri.scheme)) {
return null;
}
return uri;
}
void _updateDuration(Duration? duration) {
final item = mediaItem.value;
if (item == null || duration == null || item.duration == duration) return;
mediaItem.add(item.copyWith(duration: duration));
}
@override
Future<void> play() async {
if (_player.processingState == ProcessingState.completed) {
await _player.seek(Duration.zero);
}
await _player.play();
}
@override
Future<void> pause() => _player.pause();
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> stop() async {
await _player.stop();
await super.stop();
}
Future<void> dispose() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
await _player.dispose();
await errors.close();
}
// #***! состояние транслируем системе чтоб уведомление совпадало с плеером
void _broadcastState() {
playbackState.add(
playbackState.value.copyWith(
controls: [
if (_player.playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
],
androidCompactActionIndices: const [0],
processingState: _processingState(_player.processingState),
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
}
AudioProcessingState _processingState(ProcessingState state) {
return switch (state) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
};
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/services.dart';
import 'raw_clipboard_media.dart';
// #***! мост к нативному буферу, ошибки канала это пусто
class ClipboardChannel {
const ClipboardChannel._();
static const MethodChannel _channel = MethodChannel('ru.ikar.app/clipboard');
static Future<bool> hasMedia() async {
try {
return await _channel.invokeMethod<bool>('hasMedia') ?? false;
} catch (_) {
return false;
}
}
// #***! приоритет у файлов, есть пути картинку не разбираем
static Future<RawClipboardMedia?> read() async {
Map<Object?, Object?>? raw;
try {
raw = await _channel.invokeMapMethod<Object?, Object?>('read');
} catch (_) {
return null;
}
if (raw == null) return null;
final paths = (raw['files'] as List<Object?>?)?.whereType<String>().toList(
growable: false,
);
if (paths != null && paths.isNotEmpty) {
return RawClipboardMedia(paths: paths);
}
final image = raw['image'];
if (image is Uint8List && image.isNotEmpty) {
return RawClipboardMedia(
png: image,
imageExtension: _extension(raw['imageExtension']),
);
}
return null;
}
static String? _extension(Object? value) {
if (value is! String) return null;
final trimmed = value.startsWith('.') ? value.substring(1) : value;
if (trimmed.isEmpty || trimmed.length > 5) return null;
if (!RegExp(r'^[A-Za-z0-9]+$').hasMatch(trimmed)) return null;
return '.${trimmed.toLowerCase()}';
}
}
@@ -0,0 +1,4 @@
// #***! на вебе буфера нет, заглушка
export 'clipboard_media_stub.dart'
if (dart.library.io) 'clipboard_media_io.dart';
export 'clipboard_media_types.dart';
@@ -0,0 +1,90 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'clipboard_channel.dart';
import 'clipboard_media_types.dart';
import 'dib_image.dart';
import 'raw_clipboard_media.dart';
import 'win32_clipboard.dart';
// #***! вставка файлов и картинок из буфера
class ClipboardMedia {
const ClipboardMedia._();
// #***! на винде WinAPI, на остальных нативный канал
static bool get supported {
if (Platform.isWindows) return Win32Clipboard.instance != null;
return Platform.isMacOS ||
Platform.isLinux ||
Platform.isAndroid ||
Platform.isIOS;
}
static Future<bool> hasMedia() async {
if (Platform.isWindows) {
return Win32Clipboard.instance?.hasMedia ?? false;
}
if (!supported) return false;
return ClipboardChannel.hasMedia();
}
// #***! сначала файлы, потом PNG, потом виндовый DIB
static Future<ClipboardMediaPayload?> read() async {
final raw = await _readRaw();
if (raw == null || raw.isEmpty) return null;
if (raw.paths.isNotEmpty) {
final files = <ClipboardFileRef>[];
for (final path in raw.paths) {
final file = File(path);
if (!file.existsSync()) continue;
files.add(
ClipboardFileRef(
path: path,
name: _basename(path),
size: file.lengthSync(),
),
);
}
if (files.isNotEmpty) return ClipboardMediaPayload(files: files);
}
final png = raw.png;
if (png != null && png.isNotEmpty) {
return ClipboardMediaPayload(
image: ClipboardImageData(
bytes: png,
extension: raw.imageExtension ?? '.png',
),
);
}
// #***! DIB в PNG в отдельном изоляте
final dib = raw.dib;
if (dib != null && dib.isNotEmpty) {
final decoded = await compute(dibToPng, dib);
if (decoded != null && decoded.isNotEmpty) {
return ClipboardMediaPayload(
image: ClipboardImageData(bytes: decoded, extension: '.png'),
);
}
}
return null;
}
static Future<RawClipboardMedia?> _readRaw() {
if (Platform.isWindows) {
final clipboard = Win32Clipboard.instance;
if (clipboard == null) return Future.value();
return clipboard.read();
}
if (!supported) return Future.value();
return ClipboardChannel.read();
}
}
String _basename(String path) {
final separator = path.lastIndexOf(RegExp(r'[\\/]'));
return separator < 0 ? path : path.substring(separator + 1);
}
@@ -0,0 +1,12 @@
import 'clipboard_media_types.dart';
// #***! заглушка, буфер всегда пуст
class ClipboardMedia {
const ClipboardMedia._();
static bool get supported => false;
static Future<bool> hasMedia() async => false;
static Future<ClipboardMediaPayload?> read() async => null;
}
@@ -0,0 +1,35 @@
import 'dart:typed_data';
// #***! файл из буфера
class ClipboardFileRef {
const ClipboardFileRef({
required this.path,
required this.name,
required this.size,
});
final String path;
final String name;
final int size;
}
// #***! картинка из буфера с расширением
class ClipboardImageData {
const ClipboardImageData({required this.bytes, required this.extension});
final Uint8List bytes;
final String extension;
}
// #***! что реально достали из буфера
class ClipboardMediaPayload {
const ClipboardMediaPayload({
this.files = const <ClipboardFileRef>[],
this.image,
});
final List<ClipboardFileRef> files;
final ClipboardImageData? image;
bool get isEmpty => files.isEmpty && image == null;
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:typed_data';
import 'package:image/image.dart' as img;
// #***! константы BMP
const int _biBitfields = 3;
const int _biAlphaBitfields = 6;
const int _fileHeaderSize = 14;
const int _coreHeaderSize = 12;
const int _infoHeaderSize = 40;
// #***! винда отдаёт картинку как DIB, это BMP без файлового заголовка
Uint8List? dibToPng(Uint8List dib) {
final bmp = _wrapDibAsBmp(dib);
if (bmp == null) return null;
try {
final decoded = img.decodeBmp(bmp);
if (decoded == null) return null;
return img.encodePng(decoded);
} catch (_) {
return null;
}
}
// #***! дописываем 14 байт заголовка, смещение пикселей считаем по палитре и маскам
Uint8List? _wrapDibAsBmp(Uint8List dib) {
if (dib.length < _coreHeaderSize) return null;
final source = ByteData.sublistView(dib);
final headerSize = source.getUint32(0, Endian.little);
if (headerSize != _coreHeaderSize && headerSize < _infoHeaderSize) {
return null;
}
if (dib.length < headerSize) return null;
final int bitCount;
final int compression;
final int paletteEntries;
if (headerSize == _coreHeaderSize) {
bitCount = source.getUint16(10, Endian.little);
compression = 0;
paletteEntries = bitCount <= 8 ? 1 << bitCount : 0;
} else {
bitCount = source.getUint16(14, Endian.little);
compression = source.getUint32(16, Endian.little);
final declared = source.getUint32(32, Endian.little);
paletteEntries = bitCount <= 8
? (declared != 0 ? declared : 1 << bitCount)
: declared;
}
var extra = paletteEntries * (headerSize == _coreHeaderSize ? 3 : 4);
if (headerSize == _infoHeaderSize) {
if (compression == _biBitfields) extra += 12;
if (compression == _biAlphaBitfields) extra += 16;
}
final pixelOffset = _fileHeaderSize + headerSize + extra;
if (pixelOffset >= _fileHeaderSize + dib.length) return null;
final bmp = Uint8List(_fileHeaderSize + dib.length);
final header = ByteData.sublistView(bmp, 0, _fileHeaderSize);
header.setUint8(0, 0x42);
header.setUint8(1, 0x4D);
header.setUint32(2, bmp.length, Endian.little);
header.setUint32(10, pixelOffset, Endian.little);
bmp.setRange(_fileHeaderSize, bmp.length, dib);
return bmp;
}
@@ -0,0 +1,112 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../gallery_source.dart';
import 'clipboard_media.dart';
// #***! вставленные картинки лежат сутки
const Duration _pasteRetention = Duration(hours: 24);
// #***! как показать вставленное
enum PastedAttachmentKind { image, video, file }
// #***! файл из буфера готовый к отправке
class PastedAttachment {
const PastedAttachment({
required this.file,
required this.name,
required this.size,
required this.kind,
});
final File file;
final String name;
final int size;
final PastedAttachmentKind kind;
bool get isMedia => kind != PastedAttachmentKind.file;
}
// #***! содержимое буфера в файлы на диске
Future<List<PastedAttachment>> materializeClipboardMedia(
ClipboardMediaPayload payload,
) async {
final result = <PastedAttachment>[];
final image = payload.image;
if (image != null) {
final stored = await storePastedImage(image);
if (stored != null) result.add(stored);
}
for (final ref in payload.files) {
final file = File(ref.path);
if (!await file.exists()) continue;
result.add(
PastedAttachment(
file: file,
name: ref.name,
size: ref.size,
kind: _kindOf(ref.path),
),
);
}
return result;
}
// #***! картинку сначала сохраняем иначе её не отправить
Future<PastedAttachment?> storePastedImage(ClipboardImageData image) async {
try {
final dir = Directory(
'${(await getTemporaryDirectory()).path}/qlyra_paste',
);
await dir.create(recursive: true);
await _prune(dir);
final name =
'paste_${DateTime.now().millisecondsSinceEpoch}'
'${image.extension}';
final file = File('${dir.path}/$name');
await file.writeAsBytes(image.bytes, flush: true);
return PastedAttachment(
file: file,
name: name,
size: image.bytes.length,
kind: PastedAttachmentKind.image,
);
} catch (_) {
return null;
}
}
const Map<String, String> _imageExtensions = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/heic': '.heic',
'image/bmp': '.bmp',
};
// #***! клавиатура отдаёт mime, файлу нужно расширение
String pastedImageExtension(String mimeType) =>
_imageExtensions[mimeType.toLowerCase()] ?? '.png';
// #***! тип по расширению
PastedAttachmentKind _kindOf(String path) {
if (isVideoPath(path)) return PastedAttachmentKind.video;
if (isImagePath(path)) return PastedAttachmentKind.image;
return PastedAttachmentKind.file;
}
// #***! старые вставки чистим иначе папка растёт
Future<void> _prune(Directory dir) async {
final cutoff = DateTime.now().subtract(_pasteRetention);
try {
await for (final entry in dir.list()) {
if (entry is! File) continue;
if ((await entry.stat()).modified.isBefore(cutoff)) await entry.delete();
}
} catch (_) {}
}
@@ -0,0 +1,18 @@
import 'dart:typed_data';
// #***! сырой ответ натива, пути или байты картинки
class RawClipboardMedia {
const RawClipboardMedia({
this.paths = const <String>[],
this.png,
this.dib,
this.imageExtension,
});
final List<String> paths;
final Uint8List? png;
final Uint8List? dib;
final String? imageExtension;
bool get isEmpty => paths.isEmpty && png == null && dib == null;
}
@@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'raw_clipboard_media.dart';
const int _cfDib = 8;
const int _cfUnicodeText = 13;
const int _cfHdrop = 15;
const int _cfDibV5 = 17;
const int _pathBufferChars = 32768;
const int _maxFiles = 20;
const int _openAttempts = 8;
// #***! буфер может быть занят другим приложением, повторяем
const Duration _openRetryDelay = Duration(milliseconds: 25);
// #***! сигнатуры WinAPI для FFI
typedef _OpenClipboardC = Int32 Function(IntPtr);
typedef _OpenClipboardDart = int Function(int);
typedef _CloseClipboardC = Int32 Function();
typedef _CloseClipboardDart = int Function();
typedef _FormatAvailableC = Int32 Function(Uint32);
typedef _FormatAvailableDart = int Function(int);
typedef _GetClipboardDataC = IntPtr Function(Uint32);
typedef _GetClipboardDataDart = int Function(int);
typedef _RegisterFormatC = Uint32 Function(Pointer<Utf16>);
typedef _RegisterFormatDart = int Function(Pointer<Utf16>);
typedef _GlobalLockC = Pointer<Uint8> Function(IntPtr);
typedef _GlobalLockDart = Pointer<Uint8> Function(int);
typedef _GlobalUnlockC = Int32 Function(IntPtr);
typedef _GlobalUnlockDart = int Function(int);
typedef _GlobalSizeC = IntPtr Function(IntPtr);
typedef _GlobalSizeDart = int Function(int);
typedef _DragQueryFileC =
Uint32 Function(IntPtr, Uint32, Pointer<Utf16>, Uint32);
typedef _DragQueryFileDart = int Function(int, int, Pointer<Utf16>, int);
// #***! буфер винды напрямую через WinAPI, флаттер файлы оттуда не отдаёт
class Win32Clipboard {
Win32Clipboard._() {
final user32 = DynamicLibrary.open('user32.dll');
final kernel32 = DynamicLibrary.open('kernel32.dll');
final shell32 = DynamicLibrary.open('shell32.dll');
_open = user32.lookupFunction<_OpenClipboardC, _OpenClipboardDart>(
'OpenClipboard',
);
_close = user32.lookupFunction<_CloseClipboardC, _CloseClipboardDart>(
'CloseClipboard',
);
_formatAvailable = user32
.lookupFunction<_FormatAvailableC, _FormatAvailableDart>(
'IsClipboardFormatAvailable',
);
_getData = user32.lookupFunction<_GetClipboardDataC, _GetClipboardDataDart>(
'GetClipboardData',
);
_registerFormat = user32
.lookupFunction<_RegisterFormatC, _RegisterFormatDart>(
'RegisterClipboardFormatW',
);
_globalLock = kernel32.lookupFunction<_GlobalLockC, _GlobalLockDart>(
'GlobalLock',
);
_globalUnlock = kernel32.lookupFunction<_GlobalUnlockC, _GlobalUnlockDart>(
'GlobalUnlock',
);
_globalSize = kernel32.lookupFunction<_GlobalSizeC, _GlobalSizeDart>(
'GlobalSize',
);
_dragQueryFile = shell32
.lookupFunction<_DragQueryFileC, _DragQueryFileDart>('DragQueryFileW');
_pngFormat = _registerNamedFormat('PNG');
}
// #***! инстанс один раз, функции не нашлись значит фича выключена
static Win32Clipboard? _instance;
static bool _resolved = false;
static Win32Clipboard? get instance {
if (!Platform.isWindows) return null;
if (!_resolved) {
_resolved = true;
try {
_instance = Win32Clipboard._();
} catch (_) {
_instance = null;
}
}
return _instance;
}
late final _OpenClipboardDart _open;
late final _CloseClipboardDart _close;
late final _FormatAvailableDart _formatAvailable;
late final _GetClipboardDataDart _getData;
late final _RegisterFormatDart _registerFormat;
late final _GlobalLockDart _globalLock;
late final _GlobalUnlockDart _globalUnlock;
late final _GlobalSizeDart _globalSize;
late final _DragQueryFileDart _dragQueryFile;
late final int _pngFormat;
// #***! в буфере или файлы или картинка
bool get hasMedia => _has(_cfHdrop) || _hasImage;
bool get _hasImage =>
!_has(_cfUnicodeText) &&
(_has(_pngFormat) || _has(_cfDibV5) || _has(_cfDib));
// #***! чтение с ретраями, буфер надо открыть забрать и обязательно закрыть
Future<RawClipboardMedia?> read() async {
if (!hasMedia) return null;
for (var attempt = 0; attempt < _openAttempts; attempt++) {
if (_open(0) != 0) {
try {
final raw = _readOpened();
return raw == null || raw.isEmpty ? null : raw;
} finally {
_close();
}
}
await Future<void>.delayed(_openRetryDelay);
}
return null;
}
// #***! формат PNG не стандартный, регистрируем по имени
int _registerNamedFormat(String name) {
final native = name.toNativeUtf16();
try {
return _registerFormat(native);
} finally {
calloc.free(native);
}
}
bool _has(int format) => format != 0 && _formatAvailable(format) != 0;
RawClipboardMedia? _readOpened() {
if (_has(_cfHdrop)) {
final paths = _readPaths();
if (paths.isNotEmpty) return RawClipboardMedia(paths: paths);
}
if (!_hasImage) return null;
if (_has(_pngFormat)) {
final png = _copyGlobal(_getData(_pngFormat));
if (png != null) return RawClipboardMedia(png: png);
}
for (final format in const [_cfDibV5, _cfDib]) {
if (!_has(format)) continue;
final dib = _copyGlobal(_getData(format));
if (dib != null) return RawClipboardMedia(dib: dib);
}
return null;
}
// #***! пути достаём через DragQueryFile
List<String> _readPaths() {
final handle = _getData(_cfHdrop);
if (handle == 0) return const [];
final total = _dragQueryFile(handle, 0xFFFFFFFF, nullptr, 0);
if (total == 0) return const [];
final limit = total > _maxFiles ? _maxFiles : total;
final buffer = calloc<Uint16>(_pathBufferChars);
try {
final target = buffer.cast<Utf16>();
final paths = <String>[];
for (var i = 0; i < limit; i++) {
if (_dragQueryFile(handle, i, target, _pathBufferChars) == 0) continue;
final path = target.toDartString();
if (path.isNotEmpty) paths.add(path);
}
return paths;
} finally {
calloc.free(buffer);
}
}
// #***! данные надо залочить скопировать и разлочить
Uint8List? _copyGlobal(int handle) {
if (handle == 0) return null;
final size = _globalSize(handle);
if (size <= 0) return null;
final block = _globalLock(handle);
if (block == nullptr) return null;
try {
return Uint8List.fromList(block.asTypedList(size));
} finally {
_globalUnlock(handle);
}
}
}

Some files were not shown because too many files have changed in this diff Show More