feat: фул интеграция FCM с андроидом нах

This commit is contained in:
Jganenokk
2026-07-02 23:32:31 +07:00
parent bcef107c25
commit a904b733d2
20 changed files with 1941 additions and 90 deletions
+5 -1
View File
@@ -49,6 +49,8 @@ class Api {
int? get callsSeed => _callsSeed;
String? get deviceId => _deviceId;
String? spoofScope;
List<CountryName>? _registrationCountries;
List<CountryName> get registrationCountries =>
@@ -224,7 +226,9 @@ class Api {
);
}
final spoofed = await SpoofingService.getSpoofedSessionData();
final spoofed = await SpoofingService.getSpoofedSessionData(
scope: spoofScope,
);
if (spoofed != null) {
final sDeviceType = spoofed['device_type'] as String?;
if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType;
+1 -1
View File
@@ -504,7 +504,7 @@ class AccountModule {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.config, <dynamic, dynamic>{
'pushToken': pushToken,
'pushOptions': 131072,
'pushOptions': 0,
});
if (packet.isError) {
final msg = messageFromErrorPayload(packet.payload).toUpperCase();
+101
View File
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'call_controller.dart';
class CallBridge {
CallBridge._();
static final CallBridge instance = CallBridge._();
static const _method = MethodChannel('ru.komet.app/calls');
static const _events = EventChannel('ru.komet.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: (_) {});
}
Future<void> checkInitialCall() async {
if (!_android) return;
try {
_handle(await _method.invokeMethod<dynamic>('consumeInitialCall'));
} catch (_) {}
}
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 (_) {
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 (_) {}
}
Future<void> notifyEnded() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyEnded');
} catch (_) {}
}
Future<void> cancelIncoming() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('cancelIncoming');
} catch (_) {}
}
Future<bool> canUseFullScreenIntent() async {
if (!_android) return true;
try {
return await _method.invokeMethod<bool>('canUseFullScreenIntent') ?? true;
} catch (_) {
return true;
}
}
Future<void> openFullScreenIntentSettings() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('openFullScreenIntentSettings');
} catch (_) {}
}
}
+72 -19
View File
@@ -4,21 +4,23 @@ import '../../backend/api.dart';
import '../../backend/modules/calls.dart';
import '../protocol/opcode_map.dart';
import '../protocol/packet.dart';
import 'call_bridge.dart';
import 'call_session.dart';
import 'conversation_params.dart';
import 'ws2_signaling.dart';
/// Данные входящего звонка (из пуша opcode 137).
class IncomingCall {
final String conversationId;
/// ONE_ME id звонящего.
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,
@@ -27,11 +29,11 @@ class IncomingCall {
required this.params,
this.country,
this.isContact,
this.callerName,
this.autoAccept = false,
});
}
/// Глобальный оркестратор звонков: слушает входящие (opcode 137),
/// инициирует исходящие (opcode 78) и держит активный [CallSession].
class CallController {
CallController._();
static final CallController instance = CallController._();
@@ -42,13 +44,16 @@ class CallController {
final _incoming = StreamController<IncomingCall>.broadcast();
final _ended = StreamController<void>.broadcast();
final _canceled = StreamController<void>.broadcast();
bool appResumed = false;
/// Новый входящий звонок — UI показывает экран/оверлей.
Stream<IncomingCall> get incomingCalls => _incoming.stream;
/// Активный звонок завершился (любой стороной).
Stream<void> get callEnded => _ended.stream;
Stream<void> get incomingCanceled => _canceled.stream;
CallSession? _active;
CallSession? get activeSession => _active;
@@ -66,6 +71,7 @@ class CallController {
void _onPush(Packet packet) {
if (packet.opcode != Opcode.notifCallStart) return;
if (!appResumed) return;
final payload = packet.payload;
if (payload is! Map) return;
@@ -77,22 +83,69 @@ class CallController {
final params = ConversationParams.decode(vcp);
if (params == null) return;
// Уже идёт звонок — новый игнорируем (сервер сам отметит как пропущенный).
if (_active != null) return;
final incoming = IncomingCall(
_emitIncoming(IncomingCall(
conversationId: conversationId,
callerId: callerId,
isVideo: payload['type'] == 'VIDEO',
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 = _asInt(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 (_active != null) return;
if (_pending?.conversationId == incoming.conversationId) return;
_pending = incoming;
_incoming.add(incoming);
}
/// Начать исходящий 1:1 звонок.
void dismissIncoming() {
if (_pending == null) return;
_pending = null;
_canceled.add(null);
}
static int? _asInt(Object? v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v);
return null;
}
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) async {
if (_active != null) throw StateError('уже идёт звонок');
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
@@ -100,6 +153,7 @@ class CallController {
final session = CallSession(ws2Config: config, role: CallRole.caller);
_bind(session);
await session.start();
CallBridge.instance.notifyAccepted();
return session;
}
@@ -114,12 +168,13 @@ class CallController {
final session = CallSession(ws2Config: config, role: CallRole.joiner);
_bind(session);
await session.start();
CallBridge.instance.notifyAccepted();
return session;
}
/// Принять входящий звонок.
Future<CallSession> acceptIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.cancelIncoming();
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
@@ -132,13 +187,13 @@ class CallController {
_bind(session);
await session.start();
await session.accept();
CallBridge.instance.notifyAccepted(caller: call.callerName);
return session;
}
/// Отклонить входящий звонок (подключаемся к ws2 только чтобы отправить
/// `hangup reason=REJECTED`, без медиа).
Future<void> rejectIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.notifyEnded();
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
@@ -153,12 +208,8 @@ class CallController {
}
}
/// Завершить активный звонок.
Future<void> endActive() => _active?.hangup() ?? Future.value();
/// DEBUG: послать в активный звонок сигнал состояния микрофона
/// (`change-media-settings`), не трогая реальный микрофон.
/// Возвращает `false`, если активного звонка нет.
Future<bool> sendMicSignal(bool enabled) async {
final session = _active;
if (session == null) return false;
@@ -171,6 +222,7 @@ class CallController {
session.stateStream.listen((state) {
if (state == CallSessionState.ended && _active == session) {
_active = null;
CallBridge.instance.notifyEnded();
_ended.add(null);
}
});
@@ -180,5 +232,6 @@ class CallController {
_pushSub?.cancel();
_incoming.close();
_ended.close();
_canceled.close();
}
}
+399 -28
View File
@@ -1,65 +1,438 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../backend/api.dart';
import '../../backend/modules/account.dart';
import '../../backend/modules/messages.dart';
import '../calls/conversation_params.dart';
import '../calls/ws2_signaling.dart';
import '../protocol/opcode_map.dart';
import '../storage/app_instance.dart';
import '../storage/token_storage.dart';
import '../utils/logger.dart';
const _channelId = 'komet_messages';
const _channelName = 'Сообщения';
const _prefsTokenKey = 'fcm_push_token';
const _groupKey = 'komet_messages_group';
const _callNotifId = 424242;
const _historyLimit = 6;
@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
if (message.notification != null) return;
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _display(plugin, message);
Future<void> _backgroundHandler(RemoteMessage message) async {}
class _NotifMessage {
_NotifMessage(this.text, this.senderKey, this.senderName, this.ts);
final String text;
final String senderKey;
final String senderName;
final int ts;
}
Future<void> _display(
Future<void> _showMessageNotification(
FlutterLocalNotificationsPlugin plugin,
RemoteMessage message,
Map<String, dynamic> data,
) async {
final data = message.data;
final title = message.notification?.title ??
data['title']?.toString() ??
data['sender']?.toString() ??
'MAX';
final body = message.notification?.body ??
final chatId = int.tryParse(data['mc']?.toString() ?? '') ?? 0;
final senderKey = data['suid']?.toString() ?? '';
final senderName =
data['userName']?.toString() ?? data['title']?.toString() ?? 'MAX';
final chatTitle = data['title']?.toString() ?? senderName;
final text = data['msg']?.toString() ??
data['body']?.toString() ??
data['text']?.toString() ??
data['message']?.toString() ??
'Новое сообщение';
final ts = int.tryParse(data['ctime']?.toString() ?? '') ??
int.tryParse(data['ttime']?.toString() ?? '') ??
DateTime.now().millisecondsSinceEpoch;
final isGroup = chatTitle != senderName;
final account = int.tryParse(data['c']?.toString() ?? '') ?? 0;
final replyTo = int.tryParse(data['msgid']?.toString() ?? '');
final notifId = (chatId != 0 ? chatId : senderKey.hashCode) & 0x7fffffff;
if (!await _isActive(plugin, notifId)) {
await _clearHistory(chatId);
}
final photo = await _avatarBytes(senderKey);
final avatar = photo ?? await _initialsAvatar(senderName);
print('PUSHDBG avatar sender=$senderKey photo=${photo?.length} '
'final=${avatar?.length}');
final history = await _appendHistory(chatId, senderKey, senderName, text, ts);
final persons = <String, Person>{};
Person personFor(String key, String name) => persons.putIfAbsent(
key,
() => Person(
key: key,
name: name,
icon: (key == senderKey && avatar != null)
? ByteArrayAndroidIcon(avatar)
: null,
),
);
final messages = [
for (final h in history)
Message(
h.text,
DateTime.fromMillisecondsSinceEpoch(h.ts),
personFor(h.senderKey, h.senderName),
),
];
final style = MessagingStyleInformation(
const Person(name: 'Вы'),
conversationTitle: isGroup ? chatTitle : null,
groupConversation: isGroup,
messages: messages,
);
await plugin.show(
id: message.messageId?.hashCode ??
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: title,
body: body,
notificationDetails: const NotificationDetails(
id: notifId,
title: chatTitle,
body: text,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
styleInformation: style,
groupKey: _groupKey,
largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null,
ticker: text,
actions: account != 0
? const [
AndroidNotificationAction(
'reply',
'Ответить',
inputs: [
AndroidNotificationActionInput(label: 'Сообщение…'),
],
semanticAction: SemanticAction.reply,
),
]
: null,
),
),
payload: jsonEncode({'c': account, 'chat': chatId, 'mid': replyTo}),
);
}
Future<void> _showCallNotification(
FlutterLocalNotificationsPlugin plugin,
Map<String, dynamic> data,
) async {
final name =
data['userName']?.toString() ?? data['msg']?.toString() ?? 'Неизвестный';
final avatar = await _avatarBytes(data['suid']?.toString() ?? '') ??
await _initialsAvatar(name);
await plugin.show(
id: _callNotifId,
title: 'Входящий звонок',
body: name,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.call,
largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null,
ticker: 'Входящий звонок',
),
),
);
}
Future<List<_NotifMessage>> _appendHistory(
int chatId,
String senderKey,
String senderName,
String text,
int ts,
) async {
final prefs = await SharedPreferences.getInstance();
final key = 'notif_hist_$chatId';
final list = <Map<String, dynamic>>[];
final raw = prefs.getString(key);
if (raw != null) {
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
for (final e in decoded) {
if (e is Map) list.add(e.cast<String, dynamic>());
}
}
} catch (_) {}
}
list.add({'t': text, 'k': senderKey, 'n': senderName, 'ts': ts});
while (list.length > _historyLimit) {
list.removeAt(0);
}
await prefs.setString(key, jsonEncode(list));
return [
for (final e in list)
_NotifMessage(
e['t']?.toString() ?? '',
e['k']?.toString() ?? '',
e['n']?.toString() ?? '',
int.tryParse(e['ts']?.toString() ?? '') ?? ts,
),
];
}
Future<bool> _isActive(FlutterLocalNotificationsPlugin plugin, int id) async {
try {
final active = await plugin.getActiveNotifications();
return active.any((n) => n.id == id);
} catch (_) {
return true;
}
}
Future<void> _clearHistory(int chatId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('notif_hist_$chatId');
}
const _avatarPalette = <int>[
0xFF5B8DEF,
0xFFEF5B8D,
0xFF3FB950,
0xFFE3883A,
0xFF9B72F0,
0xFF2AA9B5,
0xFFE05252,
0xFF6A7BE0,
];
String _initialsOf(String name) {
final parts =
name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.isEmpty) return '?';
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase();
}
Future<Uint8List?> _initialsAvatar(String name) async {
try {
const size = 128;
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final paint = ui.Paint()
..isAntiAlias = true
..color = ui.Color(
_avatarPalette[name.isEmpty ? 0 : name.hashCode.abs() % _avatarPalette.length],
);
canvas.drawCircle(const ui.Offset(64, 64), 64, paint);
final builder = ui.ParagraphBuilder(
ui.ParagraphStyle(
textAlign: ui.TextAlign.center,
fontSize: 56,
fontWeight: ui.FontWeight.w600,
),
)
..pushStyle(ui.TextStyle(color: const ui.Color(0xFFFFFFFF)))
..addText(_initialsOf(name));
final paragraph = builder.build()
..layout(const ui.ParagraphConstraints(width: 128));
canvas.drawParagraph(paragraph, ui.Offset(0, (size - paragraph.height) / 2));
final image = await recorder.endRecording().toImage(size, size);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (data == null) return null;
return data.buffer.asUint8List();
} catch (_) {
return null;
}
}
Future<Uint8List?> _avatarBytes(String senderKey) async {
if (senderKey.isEmpty) return null;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('contact_cache_v1');
if (raw == null) return null;
final map = jsonDecode(raw);
if (map is! Map) return null;
final entry = map[senderKey];
final url = entry is Map ? entry['a']?.toString() : null;
if (url == null || url.isEmpty) return null;
return await _downloadBytes(url);
} catch (_) {
return null;
}
}
Future<Uint8List?> _downloadBytes(String url) async {
HttpClient? client;
try {
client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
final req = await client.getUrl(Uri.parse(url));
final resp = await req.close().timeout(const Duration(seconds: 5));
if (resp.statusCode != 200) return null;
return await consolidateHttpClientResponseBytes(resp);
} catch (_) {
return null;
} finally {
client?.close(force: true);
}
}
@pragma('vm:entry-point')
void _onNotificationResponse(NotificationResponse response) {
print('REPLYDBG cb action=${response.actionId} '
'input=${response.input} payload=${response.payload}');
if (response.actionId == 'call_decline') {
final payload = response.payload;
if (payload != null) unawaited(_handleCallDecline(payload));
return;
}
if (response.actionId != 'reply') return;
final text = response.input?.trim();
final payload = response.payload;
if (text == null || text.isEmpty || payload == null) return;
unawaited(_handleReply(payload, text));
}
Future<void> _handleCallDecline(String payloadJson) async {
String vcp;
String conversationId;
try {
final decoded = jsonDecode(payloadJson);
if (decoded is! Map) return;
vcp = decoded['vcp']?.toString() ?? '';
conversationId = decoded['conversationId']?.toString() ?? '';
} catch (_) {
return;
}
if (vcp.isEmpty || conversationId.isEmpty) return;
final params = ConversationParams.decode(vcp);
if (params == null) return;
final config = Ws2Config.fromVcp(params, conversationId: conversationId);
final signaling = Ws2Signaling(config);
try {
await signaling.connect();
await signaling.hangup(reason: 'REJECTED');
print('REPLYDBG call decline sent');
} catch (e) {
print('REPLYDBG call decline error $e');
} finally {
await signaling.close();
}
}
Future<void> _handleReply(String payloadJson, String text) async {
int account;
int chatId;
int? replyTo;
try {
final decoded = jsonDecode(payloadJson);
if (decoded is! Map) return;
account = (decoded['c'] as num?)?.toInt() ?? 0;
chatId = (decoded['chat'] as num?)?.toInt() ?? 0;
replyTo = (decoded['mid'] as num?)?.toInt();
} catch (_) {
return;
}
if (account == 0 || chatId == 0) return;
print('REPLYDBG start acc=$account chat=$chatId reply=$replyTo');
WidgetsFlutterBinding.ensureInitialized();
if (AppInstance.isNamed) {
try {
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
} catch (_) {}
}
final plugin = FlutterLocalNotificationsPlugin();
final notifId = chatId & 0x7fffffff;
Api? api;
var sent = false;
try {
final token = await TokenStorage.readToken(account);
print('REPLYDBG token=${token != null && token.isNotEmpty}');
if (token != null && token.isNotEmpty) {
api = Api()..spoofScope = '$account';
await api.connect();
if (api.state != SessionState.online) {
await api.stateStream
.firstWhere((s) => s == SessionState.online)
.timeout(const Duration(seconds: 20));
}
print('REPLYDBG online');
final login = await api.sendRequest(Opcode.login, <dynamic, dynamic>{
'token': token,
'interactive': false,
'exp': {
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
},
'presenceSync': 0,
});
print('REPLYDBG login ok=${login.isOk}');
if (login.isOk) {
await MessagesModule(api).sendMessage(
account,
chatId,
text,
replyToMessageId: replyTo,
);
sent = true;
print('REPLYDBG sent');
}
}
} catch (e) {
sent = false;
print('REPLYDBG error $e');
} finally {
await api?.disconnect();
}
if (sent) {
await _clearHistory(chatId);
await plugin.cancel(id: notifId);
} else {
await plugin.show(
id: notifId,
title: 'Komet',
body: 'Не удалось отправить ответ',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
),
),
);
}
}
class PushService {
PushService._();
static final PushService instance = PushService._();
static Future<void> clearChatNotification(int chatId) async {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.cancel(id: chatId & 0x7fffffff);
await _clearHistory(chatId);
}
final FlutterLocalNotificationsPlugin _local =
FlutterLocalNotificationsPlugin();
@@ -84,8 +457,10 @@ class PushService {
await _local.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: _onNotificationResponse,
onDidReceiveBackgroundNotificationResponse: _onNotificationResponse,
);
await _local
.resolvePlatformSpecificImplementation<
@@ -101,10 +476,6 @@ class PushService {
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
FirebaseMessaging.onMessage.listen((m) {
_display(_local, m);
});
messaging.onTokenRefresh.listen((t) async {
_token = t;
await _persistToken(t);
+4 -2
View File
@@ -115,9 +115,11 @@ class SpoofingService {
return profile;
}
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
static Future<Map<String, dynamic>?> getSpoofedSessionData({
String? scope,
}) async {
final prefs = await SharedPreferences.getInstance();
final profile = await _read(prefs, await activeScope());
final profile = await _read(prefs, scope ?? await activeScope());
if (profile == null || !profile.enabled) return null;
return {
+57
View File
@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class TypingStore {
TypingStore._();
static final TypingStore instance = TypingStore._();
static const Duration _ttl = Duration(seconds: 6);
final Map<int, Set<int>> _users = {};
final Map<int, Map<int, Timer>> _timers = {};
final Map<int, ValueNotifier<bool>> _notifiers = {};
ValueListenable<bool> listenable(int chatId) => _notifiers.putIfAbsent(
chatId,
() => ValueNotifier<bool>(_users[chatId]?.isNotEmpty ?? false),
);
bool isTyping(int chatId) => _users[chatId]?.isNotEmpty ?? false;
void markTyping(int chatId, int userId) {
final timers = _timers.putIfAbsent(chatId, () => <int, Timer>{});
timers[userId]?.cancel();
timers[userId] = Timer(_ttl, () => _remove(chatId, userId));
_users.putIfAbsent(chatId, () => <int>{}).add(userId);
_sync(chatId);
}
void clearUser(int chatId, int userId) => _remove(chatId, userId);
void clearChat(int chatId) {
final timers = _timers.remove(chatId);
if (timers != null) {
for (final timer in timers.values) {
timer.cancel();
}
}
_users.remove(chatId);
_sync(chatId);
}
void _remove(int chatId, int userId) {
_timers[chatId]?.remove(userId)?.cancel();
final users = _users[chatId];
if (users != null) {
users.remove(userId);
if (users.isEmpty) _users.remove(chatId);
}
_sync(chatId);
}
void _sync(int chatId) {
_notifiers[chatId]?.value = _users[chatId]?.isNotEmpty ?? false;
}
}
@@ -33,6 +33,7 @@ class CallScreen extends StatefulWidget {
final CallSession? session;
final IncomingCall? incoming;
final bool isGroup;
final bool autoAccept;
const CallScreen({
super.key,
@@ -41,6 +42,7 @@ class CallScreen extends StatefulWidget {
this.session,
this.incoming,
this.isGroup = false,
this.autoAccept = false,
});
@override
@@ -51,6 +53,7 @@ class _CallScreenState extends State<CallScreen>
with TickerProviderStateMixin {
CallSession? _session;
StreamSubscription<CallSessionState>? _stateSub;
StreamSubscription<void>? _canceledSub;
StreamSubscription<void>? _infoSub;
StreamSubscription<void>? _kometSub;
StreamSubscription<CallChatMessage>? _chatSub;
@@ -112,6 +115,16 @@ class _CallScreenState extends State<CallScreen>
if (incoming != null && (_name.isEmpty || _avatarUrl == null)) {
_resolvePeerInfo(incoming.callerId);
}
if (incoming != null) {
_canceledSub = CallController.instance.incomingCanceled.listen((_) {
if (mounted && _incomingPending) _close();
});
}
if (widget.autoAccept && incoming != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _incomingPending) _accept();
});
}
}
Future<void> _resolvePeerInfo(int id) async {
@@ -364,6 +377,7 @@ class _CallScreenState extends State<CallScreen>
@override
void dispose() {
_stateSub?.cancel();
_canceledSub?.cancel();
_infoSub?.cancel();
_kometSub?.cancel();
_chatSub?.cancel();
@@ -25,7 +25,10 @@ import '../profile/settings_tab.dart';
import '../auth/login_screen.dart';
import '../digital_id/digital_id_web_screen.dart';
import '../../widgets/account_switcher_overlay.dart';
import '../../widgets/animated_text_swap.dart';
import '../../../backend/api.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/config/app_stories.dart';
import '../../../backend/models/chat_folder.dart';
@@ -36,6 +39,7 @@ import '../../../backend/modules/folders.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/storage/typing_store.dart';
import '../../../main.dart'
show accountModule, api, messagesModule, appRouteObserver;
@@ -157,6 +161,8 @@ class _ChatListScreenState extends State<ChatListScreen>
StreamSubscription? _stateSub;
StreamSubscription<LoginStatus>? _loginSub;
StreamSubscription<Packet>? _typingSub;
StreamSubscription<MessageEvent>? _typingMsgSub;
Widget? _cachedChatsBody;
Object? _chatsBodyCacheKey;
@@ -500,9 +506,29 @@ class _ChatListScreenState extends State<ChatListScreen>
ChatsModule.chatsChanged.addListener(_onChatsChanged);
DraftStore.instance.revision.addListener(_onDraftsChanged);
AppStories.current.addListener(_onStoriesEnabledChanged);
_typingSub = api.pushStream
.where((p) => p.opcode == Opcode.notifTyping)
.listen(_onTypingPush);
_typingMsgSub = ChatsModule.messageEvents.listen(_onTypingMessageEvent);
unawaited(_runReload());
}
void _onTypingPush(Packet packet) {
final payload = packet.payload;
if (payload is! Map) return;
final chatId = payload['chatId'];
final userId = payload['userId'];
if (chatId is! int || userId is! int) return;
if (userId == (_profile?.id ?? 0)) return;
TypingStore.instance.markTyping(chatId, userId);
}
void _onTypingMessageEvent(MessageEvent event) {
if (event is MessageAddedEvent) {
TypingStore.instance.clearUser(event.chatId, event.message.senderId);
}
}
void _onDraftsChanged() {
if (mounted) _requestReload();
}
@@ -1065,6 +1091,8 @@ class _ChatListScreenState extends State<ChatListScreen>
AppStories.current.removeListener(_onStoriesEnabledChanged);
_loginSub?.cancel();
_stateSub?.cancel();
_typingSub?.cancel();
_typingMsgSub?.cancel();
_fabController.dispose();
_navPageAnimController.dispose();
_storiesRevealController
@@ -2171,7 +2199,6 @@ class _ChatListScreenState extends State<ChatListScreen>
String time,
String imageUrl, {
int presenceUserId = 0,
bool isTyping = false,
bool isRead = false,
int unreadCount = 0,
bool isMuted = false,
@@ -2366,45 +2393,66 @@ class _ChatListScreenState extends State<ChatListScreen>
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: draft != null
? Text.rich(
TextSpan(
children: [
TextSpan(
text: 'Черновик: ',
style: TextStyle(color: cs.error),
child: ValueListenableBuilder<bool>(
valueListenable: TypingStore.instance
.listenable(int.tryParse(id) ?? 0),
child: draft != null
? Text.rich(
TextSpan(
children: [
TextSpan(
text: 'Черновик: ',
style: TextStyle(color: cs.error),
),
TextSpan(
text: draft,
style: TextStyle(
color: cs.outline,
),
),
],
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
fontStyle: FontStyle.italic,
height: 1.2,
),
TextSpan(
text: draft,
style: TextStyle(color: cs.outline),
),
],
style: const TextStyle(
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
message,
style: TextStyle(
color: cs.outline,
fontSize: 14,
fontWeight: FontWeight.w400,
fontStyle: FontStyle.italic,
fontStyle: messageItalic
? FontStyle.italic
: FontStyle.normal,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
message,
builder: (context, typing, base) {
return AnimatedTextSwap(
showAlternate: typing,
alternate: Text(
'печатает...',
style: TextStyle(
color: isTyping ? cs.primary : cs.outline,
color: cs.primary,
fontSize: 14,
fontWeight: isTyping
? FontWeight.w500
: FontWeight.w400,
fontStyle: messageItalic
? FontStyle.italic
: FontStyle.normal,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
child: base!,
);
},
),
),
?statusIcon,
const SizedBox(width: 8),
@@ -34,6 +34,7 @@ import '../../../core/calls/call_controller.dart';
import '../calls/call_screen.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/push/push_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/cache/info_cache.dart';
@@ -333,6 +334,7 @@ class _ChatScreenState extends State<ChatScreen>
@override
void initState() {
super.initState();
unawaited(PushService.clearChatNotification(widget.chatId));
WidgetsBinding.instance.addObserver(this);
ChatsModule.chatsChanged.addListener(_onChatsBump);
_messageController.addListener(_onTextChanged);
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
class AnimatedTextSwap extends StatefulWidget {
const AnimatedTextSwap({
super.key,
required this.showAlternate,
required this.child,
required this.alternate,
this.duration = const Duration(milliseconds: 260),
this.curve = Curves.easeOutCubic,
this.slideExtent = 0.45,
this.alignment = AlignmentDirectional.centerStart,
});
final bool showAlternate;
final Widget child;
final Widget alternate;
final Duration duration;
final Curve curve;
final double slideExtent;
final AlignmentGeometry alignment;
@override
State<AnimatedTextSwap> createState() => _AnimatedTextSwapState();
}
class _AnimatedTextSwapState extends State<AnimatedTextSwap>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _t;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
value: widget.showAlternate ? 1 : 0,
);
_t = CurvedAnimation(
parent: _controller,
curve: widget.curve,
reverseCurve: widget.curve.flipped,
);
}
@override
void didUpdateWidget(AnimatedTextSwap oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.duration != oldWidget.duration) {
_controller.duration = widget.duration;
}
if (widget.showAlternate != oldWidget.showAlternate) {
widget.showAlternate ? _controller.forward() : _controller.reverse();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _t,
builder: (context, _) {
final t = _t.value;
if (t <= 0) return widget.child;
if (t >= 1) return widget.alternate;
return Stack(
alignment: widget.alignment,
children: [
Opacity(
opacity: 1 - t,
child: FractionalTranslation(
translation: Offset(0, -widget.slideExtent * t),
child: widget.child,
),
),
Opacity(
opacity: t,
child: FractionalTranslation(
translation: Offset(0, widget.slideExtent * (1 - t)),
child: widget.alternate,
),
),
],
);
},
);
}
}
+56 -11
View File
@@ -46,6 +46,7 @@ import 'backend/modules/polls.dart';
import 'backend/modules/self_check.dart';
import 'backend/modules/webapp.dart';
import 'backend/modules/digital_id.dart';
import 'core/calls/call_bridge.dart';
import 'core/calls/call_controller.dart';
import 'core/links/deep_link_service.dart';
import 'frontend/screens/calls/call_screen.dart';
@@ -233,6 +234,9 @@ class KometAppState extends State<KometApp>
late Locale _locale;
late String _fontId;
bool _isLoggingOut = false;
bool _shellReady = false;
bool _incomingRouteActive = false;
IncomingCall? _pendingIncoming;
late final ValueNotifier<Color?> accentSeed = ValueNotifier(
widget.initialAccentSeed,
);
@@ -296,6 +300,7 @@ class KometAppState extends State<KometApp>
if (isOnemeFlavor) {
await PushService.instance.init(api: api, account: accountModule);
await PushService.instance.onLoginSuccess();
await _ensureFullScreenIntentPermission();
}
}
});
@@ -303,6 +308,11 @@ class KometAppState extends State<KometApp>
_callIncomingSub = CallController.instance.incomingCalls.listen(
_onIncomingCall,
);
CallController.instance.appResumed = true;
CallBridge.instance.init();
WidgetsBinding.instance.addPostFrameCallback((_) {
CallBridge.instance.checkInitialCall();
});
_sessionExpiredSub = api.sessionExpiredStream.listen((
SessionExpiredException e,
@@ -369,18 +379,49 @@ class KometAppState extends State<KometApp>
});
}
Future<void> _onIncomingCall(IncomingCall call) async {
Future<void> _ensureFullScreenIntentPermission() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('fsi_prompted') ?? false) return;
if (await CallBridge.instance.canUseFullScreenIntent()) return;
await prefs.setBool('fsi_prompted', true);
await CallBridge.instance.openFullScreenIntentSettings();
}
void _onIncomingCall(IncomingCall call) {
_pendingIncoming = call;
_presentIncomingCall();
}
void markShellReady() {
if (_shellReady) return;
_shellReady = true;
_presentIncomingCall();
}
void _presentIncomingCall() {
final call = _pendingIncoming;
if (call == null || _incomingRouteActive || !_shellReady) return;
final navState = KometApp.navigatorKey.currentState;
if (navState == null) return;
navState.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: ContactCache.get(call.callerId) ?? '',
avatarUrl: ContactCache.getAvatar(call.callerId),
incoming: call,
),
),
);
if (navState == null) {
WidgetsBinding.instance.addPostFrameCallback((_) => _presentIncomingCall());
return;
}
_incomingRouteActive = true;
navState
.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: ContactCache.get(call.callerId) ?? call.callerName ?? '',
avatarUrl: ContactCache.getAvatar(call.callerId),
incoming: call,
autoAccept: call.autoAccept,
),
),
)
.whenComplete(() {
_incomingRouteActive = false;
if (identical(_pendingIncoming, call)) _pendingIncoming = null;
});
}
@override
@@ -407,12 +448,14 @@ class KometAppState extends State<KometApp>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
CallController.instance.appResumed = state == AppLifecycleState.resumed;
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached) {
DebugSessionLog.instance.flushNow();
}
if (state != AppLifecycleState.resumed) return;
CallBridge.instance.checkInitialCall();
if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return;
_rescheduleSwitch();
final next = _effectiveThemeMode;
@@ -839,6 +882,7 @@ class _StartupScreenState extends State<_StartupScreen> {
context,
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
);
KometApp.stateOf(context)?.markShellReady();
}
Future<int?> _recoverActiveAccount() async {
@@ -860,6 +904,7 @@ class _StartupScreenState extends State<_StartupScreen> {
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
KometApp.stateOf(context)?.markShellReady();
}
}