feat: fkm существует

This commit is contained in:
Jganenokk
2026-08-21 15:17:11 +07:00
parent dbdb378137
commit 8ce5df9b7e
20 changed files with 989 additions and 70 deletions
+8 -1
View File
@@ -3,6 +3,7 @@ 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';
@@ -72,7 +73,6 @@ class CallController {
void _onPush(Packet packet) {
if (packet.opcode != Opcode.notifCallStart) return;
if (!appResumed) return;
final payload = packet.payload;
if (payload is! Map) return;
@@ -81,6 +81,13 @@ class CallController {
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;
+80
View File
@@ -0,0 +1,80 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import '../utils/logger.dart';
/// Канал к нативному сервису FKM (foreground komet messaging).
class FkmBridge {
FkmBridge._();
static final FkmBridge instance = FkmBridge._();
static const _method = MethodChannel('ru.komet.app/fkm');
VoidCallback? _onDisabled;
bool _handlerSet = false;
bool get isSupported {
try {
return Platform.isAndroid;
} catch (_) {
return false;
}
}
/// Вызывается, когда пользователь выключил FKM кнопкой в самом уведомлении.
void setDisabledCallback(VoidCallback callback) {
_onDisabled = callback;
if (_handlerSet || !isSupported) return;
_handlerSet = true;
_method.setMethodCallHandler((call) async {
if (call.method == 'disabled') _onDisabled?.call();
return null;
});
}
Future<bool> isEnabled() async {
if (!isSupported) return false;
return await _invoke<bool>('isEnabled') ?? false;
}
Future<void> setEnabled(bool enabled) =>
_invoke<void>('setEnabled', {'enabled': enabled});
Future<void> setConnected(bool connected) =>
_invoke<void>('setConnected', {'connected': connected});
Future<void> showMessage(Map<String, String> data) =>
_invoke<void>('showMessage', {'data': data});
Future<void> showCall(Map<String, String> data) =>
_invoke<void>('showCall', {'data': data});
Future<bool> hasNotificationPermission() async {
if (!isSupported) return false;
return await _invoke<bool>('hasNotificationPermission') ?? false;
}
Future<bool> requestNotificationPermission() async {
if (!isSupported) return false;
return await _invoke<bool>('requestNotificationPermission') ?? false;
}
Future<bool> isIgnoringBatteryOptimizations() async {
if (!isSupported) return true;
return await _invoke<bool>('isIgnoringBatteryOptimizations') ?? true;
}
Future<void> requestIgnoreBatteryOptimizations() =>
_invoke<void>('requestIgnoreBatteryOptimizations');
Future<T?> _invoke<T>(String method, [Map<String, dynamic>? args]) async {
if (!isSupported) return null;
try {
return await _method.invokeMethod<T>(method, args);
} catch (e) {
logger.w('FkmBridge.$method: $e');
return null;
}
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../backend/api.dart';
import '../../backend/modules/account/account_models.dart';
import '../../backend/modules/chat_preview.dart';
import '../../backend/modules/chats.dart';
import '../../backend/modules/messages.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
import '../utils/logger.dart';
import 'fkm_bridge.dart';
import 'push_service.dart';
const _fallbackSender = 'MAX';
const _hiddenPreview = 'Новое сообщение';
/// FKM — уведомления через собственное фоновое соединение, без FCM.
///
/// Пуш из сокета превращается в тот же набор полей, что присылает FCM, и
/// отрисовывается нативным `KometNotifier` — общий код с пушевой версией.
class FkmController {
FkmController._();
static final FkmController instance = FkmController._();
final ValueNotifier<bool> enabled = ValueNotifier(false);
Api? _api;
StreamSubscription<Packet>? _pushSub;
StreamSubscription<SessionState>? _stateSub;
bool _started = false;
bool get isSupported => FkmBridge.instance.isSupported;
Future<void> init(Api api) async {
if (_started || !isSupported) return;
_started = true;
_api = api;
FkmBridge.instance.setDisabledCallback(_onDisabledFromNotification);
enabled.value = await FkmBridge.instance.isEnabled();
_pushSub = api.pushStream
.where((packet) => packet.opcode == Opcode.notifMessage)
.listen(_onMessagePush);
_stateSub = api.stateStream.listen(_onSessionState);
if (enabled.value) {
await initLocalNotificationActions();
await FkmBridge.instance.setEnabled(true);
await _pushConnectionState();
}
}
/// Возвращает false, если пользователь не выдал разрешение на уведомления.
Future<bool> setEnabled(bool value) async {
if (!isSupported) return false;
if (value && !await FkmBridge.instance.requestNotificationPermission()) {
return false;
}
if (value) await initLocalNotificationActions();
await FkmBridge.instance.setEnabled(value);
enabled.value = value;
if (value) await _pushConnectionState();
return true;
}
void _onDisabledFromNotification() => enabled.value = false;
void _onSessionState(SessionState state) {
if (!enabled.value) return;
unawaited(FkmBridge.instance.setConnected(state == SessionState.online));
}
Future<void> _pushConnectionState() => FkmBridge.instance.setConnected(
_api?.state == SessionState.online,
);
/// Входящий звонок, когда приложение не на переднем плане.
///
/// Отдаётся тому же нативному коду, что и FCM-пуш: CallStyle, полноэкранный
/// интент, рингтон, приём и отклонение уже реализованы там.
Future<void> showIncomingCall(Map<dynamic, dynamic> payload) async {
if (!enabled.value) return;
try {
final data = await _buildCallNotification(payload);
if (data != null) await FkmBridge.instance.showCall(data);
} catch (e) {
logger.w('FKM: не удалось показать звонок: $e');
}
}
Future<Map<String, String>?> _buildCallNotification(
Map<dynamic, dynamic> payload,
) async {
final vcp = payload['vcp'];
final conversationId = payload['conversationId'];
final callerId = payload['callerId'];
if (vcp is! String || conversationId is! String || callerId is! int) {
return null;
}
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return null;
final rawConfig = await AppDatabase.getPrivacyConfig(accountId);
if (rawConfig != null &&
PrivacyConfig.fromJson(rawConfig).mCallPushNotification != 'ON') {
return null;
}
final name = ContactCache.get(callerId) ?? _fallbackSender;
return {
'type': 'InboundCall',
'vcp': vcp,
'conversationId': conversationId,
'callerId': '$callerId',
'suid': '$callerId',
'userName': name,
'title': name,
'c': '$accountId',
'iv': payload['type'] == 'VIDEO' ? 'true' : 'false',
};
}
Future<void> _onMessagePush(Packet packet) async {
if (!enabled.value) return;
try {
final data = await _buildNotification(packet);
if (data != null) await FkmBridge.instance.showMessage(data);
} catch (e) {
logger.w('FKM: не удалось показать уведомление: $e');
}
}
Future<Map<String, String>?> _buildNotification(Packet packet) async {
final payload = packet.payload;
if (payload is! Map) return null;
final chatId = payload['chatId'];
if (chatId is! int) return null;
final msg = payload['message'];
if (msg is! Map) return null;
if (payload['postId'] != null || msg['postId'] != null) return null;
final status = msg['status']?.toString();
if (status == 'REMOVED' || status == 'EDITED') return null;
final senderId = msg['sender'];
if (senderId is! int) return null;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null || senderId == accountId) return null;
final rawConfig = await AppDatabase.getPrivacyConfig(accountId);
var showPreview = true;
if (rawConfig != null) {
final config = PrivacyConfig.fromJson(rawConfig);
if (config.chatsPushNotification != 'ON') return null;
showPreview = config.pushDetails;
}
final rows = await AppDatabase.loadChat(accountId, chatId);
final chat = rows.isEmpty ? null : CachedChat.fromDbRow(rows.first);
if (chat != null && chat.isMuted) return null;
final senderName =
ContactCache.get(senderId) ?? chat?.title ?? _fallbackSender;
final chatTitle = (chat != null && chat.isGroupChat)
? (chat.title ?? senderName)
: senderName;
final text = showPreview ? _previewText(msg) : _hiddenPreview;
final time = msg['time'];
final msgId = msg['id'];
return {
'mc': '$chatId',
'c': '$accountId',
'suid': '$senderId',
'userName': senderName,
'title': chatTitle,
'msg': text,
'ctime': '${time is int ? time : DateTime.now().millisecondsSinceEpoch}',
if (msgId != null) 'msgid': '$msgId',
};
}
String _previewText(Map<dynamic, dynamic> msg) {
final text = msg['text']?.toString().trim();
if (text != null && text.isNotEmpty) return text;
final attach = attachPreviewLabel(msg['attaches']);
if (attach != null && attach.isNotEmpty) return attach;
return _hiddenPreview;
}
void dispose() {
_pushSub?.cancel();
_stateSub?.cancel();
_pushSub = null;
_stateSub = null;
_started = false;
}
}
+31 -21
View File
@@ -148,6 +148,36 @@ Future<void> _handleReply(String payloadJson, String text) async {
}
}
bool _localActionsReady = false;
/// Инициализация локальных уведомлений и их action-коллбэков.
///
/// Нужна и FCM, и FKM: без неё кнопка «Ответить» в уведомлении не доезжает
/// до фонового изолята.
Future<void> initLocalNotificationActions() async {
if (_localActionsReady) return;
_localActionsReady = true;
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: _onNotificationResponse,
onDidReceiveBackgroundNotificationResponse: _onNotificationResponse,
);
await plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
}
class PushService {
PushService._();
static final PushService instance = PushService._();
@@ -158,9 +188,6 @@ class PushService {
await _clearHistory(chatId);
}
final FlutterLocalNotificationsPlugin _local =
FlutterLocalNotificationsPlugin();
Api? _api;
AccountModule? _account;
String? _token;
@@ -180,24 +207,7 @@ class PushService {
_initialized = true;
await _local.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: _onNotificationResponse,
onDidReceiveBackgroundNotificationResponse: _onNotificationResponse,
);
await _local
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
await initLocalNotificationActions();
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();