Import IKAR messenger client and server
This commit is contained in:
Vendored
+379
@@ -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
@@ -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();
|
||||
}
|
||||
Vendored
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)}';
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 'Неизменяемая';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 'По расписанию';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": ["", "-", "-", ""]}
|
||||
]''';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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?);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user