From 8ce5df9b7e14277ddf302817bd14ad97eab4aaa7 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 21 Aug 2026 15:17:11 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20fkm=20=D1=81=D1=83=D1=89=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D1=83=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 21 ++ .../main/kotlin/ru/komet/app/FkmChannel.kt | 143 ++++++++++ .../main/kotlin/ru/komet/app/FkmService.kt | 254 ++++++++++++++++++ .../kotlin/ru/komet/app/KometFcmService.kt | 6 + .../main/kotlin/ru/komet/app/MainActivity.kt | 31 ++- .../app/src/main/res/values-ru/strings.xml | 8 + android/app/src/main/res/values/strings.xml | 8 + lib/core/calls/call_controller.dart | 9 +- lib/core/push/fkm_bridge.dart | 80 ++++++ lib/core/push/fkm_controller.dart | 210 +++++++++++++++ lib/core/push/push_service.dart | 52 ++-- lib/frontend/screens/chats/chat_screen.dart | 33 ++- .../screens/profile/notifications_screen.dart | 76 ++++-- lib/frontend/widgets/max_link_nav.dart | 7 +- lib/l10n/app_en.arb | 8 +- lib/l10n/app_localizations.dart | 48 +++- lib/l10n/app_localizations_en.dart | 27 +- lib/l10n/app_localizations_ru.dart | 28 +- lib/l10n/app_ru.arb | 8 +- lib/main.dart | 2 + 20 files changed, 989 insertions(+), 70 deletions(-) create mode 100644 android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt create mode 100644 android/app/src/main/kotlin/ru/komet/app/FkmService.kt create mode 100644 lib/core/push/fkm_bridge.dart create mode 100644 lib/core/push/fkm_controller.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ae75f4b..a01cf1a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,9 @@ + + + @@ -126,6 +129,24 @@ android:name=".CallForegroundService" android:foregroundServiceType="microphone|mediaProjection" android:exported="false" /> + + + + + + + + + diff --git a/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt b/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt new file mode 100644 index 0000000..d5f0e7c --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt @@ -0,0 +1,143 @@ +package ru.komet.app + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors + +object FkmChannel { + private const val NAME = "ru.komet.app/fkm" + const val NOTIF_PERMS_REQUEST = 7713 + + private val main = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadExecutor() + + private var channel: MethodChannel? = null + private var permResult: MethodChannel.Result? = null + + fun attach(engine: FlutterEngine, activity: Activity) { + val ctx = activity.applicationContext + FkmState.restore(ctx) + val ch = MethodChannel(engine.dartExecutor.binaryMessenger, NAME) + channel = ch + ch.setMethodCallHandler { call, result -> + when (call.method) { + "isEnabled" -> result.success(FkmState.enabled) + + "setEnabled" -> { + val enabled = call.argument("enabled") ?: false + FkmState.applyEnabled(ctx, enabled) + if (enabled) FkmService.start(ctx) else FkmService.stop(ctx) + result.success(null) + } + + "setConnected" -> { + FkmState.connected = call.argument("connected") ?: false + FkmService.refresh(ctx) + result.success(null) + } + + "showMessage" -> result.success(deliver(ctx, call, "showMessage")) + + "showCall" -> result.success(deliver(ctx, call, "showCall")) + + "hasNotificationPermission" -> + result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled()) + + "requestNotificationPermission" -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + NotificationManagerCompat.from(ctx).areNotificationsEnabled() + ) { + result.success( + NotificationManagerCompat.from(ctx).areNotificationsEnabled(), + ) + } else { + permResult?.success(false) + permResult = result + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIF_PERMS_REQUEST, + ) + } + } + + "isIgnoringBatteryOptimizations" -> { + val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + result.success(power.isIgnoringBatteryOptimizations(ctx.packageName)) + } + + "requestIgnoreBatteryOptimizations" -> { + try { + activity.startActivity( + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${ctx.packageName}"), + ), + ) + } catch (e: Exception) { + Log.w("Fkm", "battery settings failed: ${e.message}") + } + result.success(null) + } + + else -> result.notImplemented() + } + } + } + + // Отрисовка тянет аватарку по сети — только не на главном потоке. + private fun deliver(ctx: Context, call: MethodCall, tag: String): Boolean { + val data = call.argument>("data") ?: return false + worker.execute { + try { + KometNotifier(ctx).handle(data) + FkmState.countDelivered(ctx) + FkmService.refresh(ctx) + } catch (e: Exception) { + Log.w("Fkm", "$tag failed: ${e.message}") + } + } + return true + } + + fun detach() { + channel?.setMethodCallHandler(null) + channel = null + permResult?.success(false) + permResult = null + } + + fun onPermissionResult(grantResults: IntArray) { + val pending = permResult ?: return + permResult = null + pending.success( + grantResults.isNotEmpty() && + grantResults.all { it == PackageManager.PERMISSION_GRANTED }, + ) + } + + fun notifyDisabled() { + main.post { + try { + channel?.invokeMethod("disabled", null) + } catch (e: Exception) { + Log.w("Fkm", "notifyDisabled failed: ${e.message}") + } + } + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/FkmService.kt b/android/app/src/main/kotlin/ru/komet/app/FkmService.kt new file mode 100644 index 0000000..0a5fe09 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/FkmService.kt @@ -0,0 +1,254 @@ +package ru.komet.app + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat + +object FkmState { + private const val PREFS = "komet_fkm" + private const val KEY_ENABLED = "enabled" + private const val KEY_DELIVERED = "delivered" + + @Volatile + var enabled = false + private set + + @Volatile + var connected = false + + @Volatile + var delivered = 0 + private set + + private fun prefs(ctx: Context) = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun restore(ctx: Context) { + val p = prefs(ctx) + enabled = p.getBoolean(KEY_ENABLED, false) + delivered = p.getInt(KEY_DELIVERED, 0) + } + + fun applyEnabled(ctx: Context, value: Boolean) { + enabled = value + // Счётчик обнуляем только на выключении, чтобы перезапуск приложения + // не сбрасывал накопленное. + if (!value) { + delivered = 0 + connected = false + } + prefs(ctx).edit() + .putBoolean(KEY_ENABLED, value) + .putInt(KEY_DELIVERED, delivered) + .apply() + } + + fun countDelivered(ctx: Context) { + delivered += 1 + prefs(ctx).edit().putInt(KEY_DELIVERED, delivered).apply() + } +} + +object FkmNotification { + const val CHANNEL_ID = "komet_fkm" + const val NOTIFICATION_ID = 424244 + + fun build(ctx: Context): Notification { + ensureChannel(ctx) + + val status = if (FkmState.connected) { + ctx.getString(R.string.fkm_status_active) + } else { + ctx.getString(R.string.fkm_status_inactive) + } + val title = ctx.getString(R.string.fkm_title) + val text = ctx.getString(R.string.fkm_status_line, status, FkmState.delivered) + + val immutable = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + val open = PendingIntent.getActivity( + ctx, + 0, + Intent(ctx, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + immutable, + ) + val disable = PendingIntent.getBroadcast( + ctx, + 1, + Intent(ctx, FkmDisableReceiver::class.java).apply { + action = FkmDisableReceiver.ACTION_DISABLE + }, + immutable, + ) + + return NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(CallConst.ACCENT) + .setContentTitle(title) + .setContentText(text) + .setStyle( + NotificationCompat.BigTextStyle() + .setBigContentTitle(title) + .bigText("$text\n\n${ctx.getString(R.string.fkm_explain)}"), + ) + .setContentIntent(open) + .addAction(0, ctx.getString(R.string.fkm_disable), disable) + .setOngoing(true) + .setSilent(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .build() + } + + fun ensureChannel(ctx: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = manager(ctx) + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + ctx.getString(R.string.fkm_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = ctx.getString(R.string.fkm_channel_description) + setShowBadge(false) + setSound(null, null) + enableVibration(false) + enableLights(false) + }, + ) + } + + fun manager(ctx: Context): NotificationManager = + ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager +} + +class FkmService : Service() { + + companion object { + fun start(ctx: Context) { + val intent = Intent(ctx, FkmService::class.java) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(intent) + } else { + ctx.startService(intent) + } + } catch (e: Exception) { + Log.w("Fkm", "service start failed: ${e.message}") + } + } + + // Перерисовка уже висящего уведомления, без перезапуска сервиса. + fun refresh(ctx: Context) { + if (!FkmState.enabled) return + try { + FkmNotification.manager(ctx) + .notify(FkmNotification.NOTIFICATION_ID, FkmNotification.build(ctx)) + } catch (e: Exception) { + Log.w("Fkm", "notification refresh failed: ${e.message}") + } + } + + fun stop(ctx: Context) { + try { + ctx.stopService(Intent(ctx, FkmService::class.java)) + } catch (e: Exception) { + Log.w("Fkm", "service stop failed: ${e.message}") + } + } + } + + private var inForeground = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + FkmState.restore(applicationContext) + FkmNotification.ensureChannel(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!FkmState.enabled) { + stopForeground(STOP_FOREGROUND_REMOVE) + inForeground = false + stopSelf() + return START_NOT_STICKY + } + goForeground() + return START_STICKY + } + + override fun onDestroy() { + if (inForeground) { + stopForeground(STOP_FOREGROUND_REMOVE) + inForeground = false + } + super.onDestroy() + } + + private fun goForeground() { + val notification = FkmNotification.build(this) + if (inForeground) { + FkmNotification.manager(this) + .notify(FkmNotification.NOTIFICATION_ID, notification) + return + } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + FkmNotification.NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(FkmNotification.NOTIFICATION_ID, notification) + } + inForeground = true + } catch (e: Exception) { + Log.w("Fkm", "startForeground failed: ${e.message}") + stopSelf() + } + } +} + +class FkmDisableReceiver : BroadcastReceiver() { + companion object { + const val ACTION_DISABLE = "ru.komet.app.FKM_DISABLE" + } + + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action != ACTION_DISABLE) return + val app = ctx.applicationContext + FkmState.applyEnabled(app, false) + FkmService.stop(app) + FkmChannel.notifyDisabled() + } +} + +class FkmBootReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + val app = ctx.applicationContext + FkmState.restore(app) + if (!FkmState.enabled) return + // Движка после ребута нет — уведомление честно скажет «не активно», + // пока приложение не откроют. + FkmState.connected = false + FkmService.start(app) + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt index ffff9c4..e65d1e9 100644 --- a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt @@ -30,6 +30,12 @@ class KometFcmService : FirebaseMessagingService() { val data = message.data Log.d("KometFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}") if (data.isEmpty()) return + val type = data["type"] + FkmState.restore(applicationContext) + if (FkmState.enabled && type != "InboundCall" && type != "CallFinished") { + Log.d("KometFcm", "message push dropped: FKM handles messages") + return + } KometNotifier(applicationContext).handle(data) } } diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 39e3418..5ed4615 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -81,7 +81,7 @@ class MainActivity : FlutterActivity() { private companion object { const val LOG_TAG = "VpnBypass" const val NFC_TAG = "NfcExchange" - const val CALL_ENGINE_ID = "komet_call_engine" + const val KEEP_ENGINE_ID = "komet_keep_engine" const val NFC_PHASE_MIN_MS = 350L const val NFC_PHASE_JITTER_MS = 400 const val BLE_PERMS_REQUEST = 7711 @@ -438,6 +438,8 @@ class MainActivity : FlutterActivity() { ChatNotifications.sink = null } }) + + FkmChannel.attach(flutterEngine, this) } override fun onCreate(savedInstanceState: Bundle?) { @@ -738,6 +740,10 @@ class MainActivity : FlutterActivity() { grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == FkmChannel.NOTIF_PERMS_REQUEST) { + FkmChannel.onPermissionResult(grantResults) + return + } if (requestCode == NOTE_PERMS_REQUEST) { val pending = notePermResult notePermResult = null @@ -813,30 +819,35 @@ class MainActivity : FlutterActivity() { } } + // Движок переживает смерть активити, пока идёт звонок или включён FKM: + // в обоих случаях в фоне должно жить то же соединение, что и в UI. + private fun keepEngineAlive(): Boolean = CallState.inCall || FkmState.enabled + override fun provideFlutterEngine(context: Context): FlutterEngine? { val cache = FlutterEngineCache.getInstance() - val cached = cache.get(CALL_ENGINE_ID) + val cached = cache.get(KEEP_ENGINE_ID) if (cached != null) { - if (CallState.inCall) return cached - cache.remove(CALL_ENGINE_ID) + if (keepEngineAlive()) return cached + cache.remove(KEEP_ENGINE_ID) cached.destroy() } return super.provideFlutterEngine(context) } - override fun shouldDestroyEngineWithHost(): Boolean = !CallState.inCall + override fun shouldDestroyEngineWithHost(): Boolean = !keepEngineAlive() override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { - if (!CallState.inCall) { - FlutterEngineCache.getInstance().remove(CALL_ENGINE_ID) + if (!keepEngineAlive()) { + FlutterEngineCache.getInstance().remove(KEEP_ENGINE_ID) + FkmChannel.detach() } super.cleanUpFlutterEngine(flutterEngine) } override fun onDestroy() { - if (CallState.inCall && isFinishing) { - Log.d("KometFcm", "task removed during call, caching engine") - flutterEngine?.let { FlutterEngineCache.getInstance().put(CALL_ENGINE_ID, it) } + if (keepEngineAlive() && isFinishing) { + Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})") + flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) } } super.onDestroy() } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 6bfe8d3..dbd28c8 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -1,3 +1,11 @@ Отправка медиа + Сервис уведомлений + Держит фоновое соединение с сервером + Komet · сервис уведомлений + Соединение активно + Соединение не активно + %1$s · принято %2$d + Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM. + Выключить diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2274df6..82ae05a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,4 +2,12 @@ Komet contact exchange Komet contact exchange Sending media + Notification service + Keeps the background connection to the server alive + Komet · notification service + Connection active + Connection inactive + %1$s · %2$d delivered + This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM. + Turn off diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index 8e893b4..b6fc253 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -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; diff --git a/lib/core/push/fkm_bridge.dart b/lib/core/push/fkm_bridge.dart new file mode 100644 index 0000000..d8b4a97 --- /dev/null +++ b/lib/core/push/fkm_bridge.dart @@ -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 isEnabled() async { + if (!isSupported) return false; + return await _invoke('isEnabled') ?? false; + } + + Future setEnabled(bool enabled) => + _invoke('setEnabled', {'enabled': enabled}); + + Future setConnected(bool connected) => + _invoke('setConnected', {'connected': connected}); + + Future showMessage(Map data) => + _invoke('showMessage', {'data': data}); + + Future showCall(Map data) => + _invoke('showCall', {'data': data}); + + Future hasNotificationPermission() async { + if (!isSupported) return false; + return await _invoke('hasNotificationPermission') ?? false; + } + + Future requestNotificationPermission() async { + if (!isSupported) return false; + return await _invoke('requestNotificationPermission') ?? false; + } + + Future isIgnoringBatteryOptimizations() async { + if (!isSupported) return true; + return await _invoke('isIgnoringBatteryOptimizations') ?? true; + } + + Future requestIgnoreBatteryOptimizations() => + _invoke('requestIgnoreBatteryOptimizations'); + + Future _invoke(String method, [Map? args]) async { + if (!isSupported) return null; + try { + return await _method.invokeMethod(method, args); + } catch (e) { + logger.w('FkmBridge.$method: $e'); + return null; + } + } +} diff --git a/lib/core/push/fkm_controller.dart b/lib/core/push/fkm_controller.dart new file mode 100644 index 0000000..f0ff1d9 --- /dev/null +++ b/lib/core/push/fkm_controller.dart @@ -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 enabled = ValueNotifier(false); + + Api? _api; + StreamSubscription? _pushSub; + StreamSubscription? _stateSub; + bool _started = false; + + bool get isSupported => FkmBridge.instance.isSupported; + + Future 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 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 _pushConnectionState() => FkmBridge.instance.setConnected( + _api?.state == SessionState.online, + ); + + /// Входящий звонок, когда приложение не на переднем плане. + /// + /// Отдаётся тому же нативному коду, что и FCM-пуш: CallStyle, полноэкранный + /// интент, рингтон, приём и отклонение уже реализованы там. + Future showIncomingCall(Map 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?> _buildCallNotification( + Map 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 _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?> _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 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; + } +} diff --git a/lib/core/push/push_service.dart b/lib/core/push/push_service.dart index ab7b020..f123de9 100644 --- a/lib/core/push/push_service.dart +++ b/lib/core/push/push_service.dart @@ -148,6 +148,36 @@ Future _handleReply(String payloadJson, String text) async { } } +bool _localActionsReady = false; + +/// Инициализация локальных уведомлений и их action-коллбэков. +/// +/// Нужна и FCM, и FKM: без неё кнопка «Ответить» в уведомлении не доезжает +/// до фонового изолята. +Future 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(); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 43f1f91..03253a6 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -90,6 +90,7 @@ import 'package:komet/core/config/app_frost.dart'; import 'package:komet/core/config/app_composer_style.dart'; import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; +import '../../../models/contact_info.dart'; import '../../../models/sticker.dart'; import '../../commands/command_registry.dart'; import '../../commands/slash_command.dart'; @@ -842,17 +843,24 @@ class _ChatScreenState extends State final peerId = widget.chatId ^ _myId; if (peerId <= 0) return; final cached = ContactInfoFetch.peek(peerId); - if (cached != null) _applyPeerKind(cached.isBot); + if (cached != null) _applyPeerInfo(peerId, cached); final info = await ContactInfoFetch.get(peerId); - if (info != null) _applyPeerKind(info.isBot); + if (info != null) _applyPeerInfo(peerId, info); if ((info ?? cached)?.isBot ?? false) { unawaited(BotInfoFetch.get(peerId)); } } - void _applyPeerKind(bool isBot) { - if (!mounted || _peerIsBot == isBot) return; - setState(() => _peerIsBot = isBot); + void _applyPeerInfo(int peerId, ContactInfo info) { + if (!mounted) return; + final avatar = info.avatarUrl; + final avatarIsNew = + avatar != null && + avatar.isNotEmpty && + ContactCache.getAvatar(peerId) != avatar; + if (avatarIsNew) ContactCache.putAvatar(peerId, avatar); + if (_peerIsBot == info.isBot && !avatarIsNew) return; + setState(() => _peerIsBot = info.isBot); } Future _fastPreloadCache() async { @@ -1202,7 +1210,7 @@ class _ChatScreenState extends State builder: (_) => ChatInfoScreen( chatId: widget.chatId, name: _headerName(), - imageUrl: widget.imageUrl, + imageUrl: _headerAvatarUrl(), chatType: widget.chatType, heroTag: _profileHeroTag, initialTab: initialTab, @@ -3078,6 +3086,17 @@ class _ChatScreenState extends State if (mounted) setState(() {}); } + String _headerAvatarUrl() { + if (!_commentsMode && widget.chatType == 'DIALOG') { + final otherId = _resolveOtherId(); + if (otherId != null) { + final cached = ContactCache.getAvatar(otherId); + if (cached != null && cached.isNotEmpty) return cached; + } + } + return widget.imageUrl; + } + String _headerName() { if (_commentsMode) return AppLocalizations.of(context)!.commentsTitle; if (widget.chatType == 'DIALOG') { @@ -3178,7 +3197,7 @@ class _ChatScreenState extends State chatId: widget.chatId, heroTag: _profileHeroTag, name: _headerName(), - imageUrl: widget.imageUrl, + imageUrl: _headerAvatarUrl(), chatType: widget.chatType, isOfficial: chat?.isOfficial ?? false, encrypted: _encryptionEnabled, diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index c15fb50..cb5c619 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -3,9 +3,12 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/push/fkm_bridge.dart'; +import '../../../core/push/fkm_controller.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, isOnemeFlavor; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; @@ -24,6 +27,7 @@ class _NotificationsScreenState extends State with ReloadOnReconnect { bool _loading = true; bool _saving = false; + bool _fkmBusy = false; bool _allNotifications = true; bool _messagePreview = true; @@ -85,17 +89,55 @@ class _NotificationsScreenState extends State if (mounted) setState(() => _hapticsEnabled = value); } - void _onFkmTap() { + Future _onFkmChanged(bool value) async { final l10n = AppLocalizations.of(context)!; - final String message; - if (Platform.isIOS) { - message = l10n.notificationsFkmIosUnsupported; - } else if (isOnemeFlavor) { - message = l10n.notificationsFkmAlreadyHasFcm; - } else { - message = l10n.notificationsFkmDownloadFcm; + if (!FkmController.instance.isSupported) { + showCustomNotification( + context, + Platform.isIOS + ? l10n.notificationsFkmIosUnsupported + : l10n.notificationsFkmUnsupported, + ); + return; } - showCustomNotification(context, message); + if (_fkmBusy) return; + + if (value && isOnemeFlavor) { + final confirmed = await showConfirmDialog( + context, + title: l10n.notificationsFkmAlreadyHasFcm, + message: l10n.notificationsFkmConfirmMessage, + confirmLabel: l10n.notificationsFkmConfirmAction, + ); + if (!confirmed) return; + } + + setState(() => _fkmBusy = true); + try { + final applied = await FkmController.instance.setEnabled(value); + if (!mounted) return; + if (!applied) { + showCustomNotification(context, l10n.notificationsFkmPermissionDenied); + return; + } + if (value) await _offerBatteryExemption(); + } finally { + if (mounted) setState(() => _fkmBusy = false); + } + } + + Future _offerBatteryExemption() async { + if (await FkmBridge.instance.isIgnoringBatteryOptimizations()) return; + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final confirmed = await showConfirmDialog( + context, + title: l10n.notificationsFkmBatteryTitle, + message: l10n.notificationsFkmBatteryMessage, + confirmLabel: l10n.notificationsFkmBatteryAction, + ); + if (!confirmed) return; + await FkmBridge.instance.requestIgnoreBatteryOptimizations(); } @override @@ -124,12 +166,16 @@ class _NotificationsScreenState extends State ), SettingsCard( children: [ - SettingsToggleTile( - icon: Symbols.notifications_active, - label: l10n.notificationsFkmEnableLabel, - subtitle: l10n.notificationsFkmEnableSubtitle, - value: false, - onChanged: (_) => _onFkmTap(), + ValueListenableBuilder( + valueListenable: FkmController.instance.enabled, + builder: (context, fkmEnabled, _) => SettingsToggleTile( + icon: Symbols.notifications_active, + label: l10n.notificationsFkmEnableLabel, + subtitle: l10n.notificationsFkmEnableSubtitle, + value: fkmEnabled, + enabled: !_fkmBusy, + onChanged: _onFkmChanged, + ), ), ], ), diff --git a/lib/frontend/widgets/max_link_nav.dart b/lib/frontend/widgets/max_link_nav.dart index 58b76dc..ff66f22 100644 --- a/lib/frontend/widgets/max_link_nav.dart +++ b/lib/frontend/widgets/max_link_nav.dart @@ -72,16 +72,21 @@ Future openChatAtMessage( } final title = chat.title?.trim(); + final peerId = (chat.type == 'DIALOG' && chatId != 0) ? chatId ^ myId : 0; final name = (title != null && title.isNotEmpty) ? title : (ContactCache.get(chatId ^ myId) ?? 'Чат'); + final imageUrl = + (peerId > 0 ? ContactCache.getAvatar(peerId) : null) ?? + chat.iconUrl ?? + ''; await pushSwipeable( context, (_) => ChatScreen( chatId: chatId, name: name, - imageUrl: chat.iconUrl ?? '', + imageUrl: imageUrl, chatType: chat.type, initialMessageId: messageId, initialMessageTime: messageTime, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 765d113..2405fd6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -257,12 +257,18 @@ } }, "notificationsFkmAlreadyHasFcm": "Why? You already have FCM.", - "notificationsFkmDownloadFcm": "Better download the FCM version.", "notificationsFkmIosUnsupported": "Push notifications are not available on iOS yet.", "notificationsTitle": "Notifications", "notificationsFkmSectionTitle": "FKM", "notificationsFkmEnableLabel": "Enable notifications", "notificationsFkmEnableSubtitle": "For FKM notifications to work, the app will need to keep a notification in the shade.", + "notificationsFkmUnsupported": "FKM is Android-only", + "notificationsFkmBatteryAction": "Open settings", + "notificationsFkmBatteryMessage": "Otherwise the system will put the background connection to sleep and notifications will be late or lost.", + "notificationsFkmBatteryTitle": "Turn off battery saving?", + "notificationsFkmPermissionDenied": "FKM cannot work without the notification permission", + "notificationsFkmConfirmAction": "Enable FKM", + "notificationsFkmConfirmMessage": "Notifications will arrive over the app’s own background connection, and a permanent service notification will stay in the shade. You can turn FKM off right from it.", "notificationsMainSectionTitle": "Notifications", "notificationsAllLabel": "All notifications", "notificationsNewSectionTitle": "New notifications", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 77a6c39..a0db0ca 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1412,12 +1412,6 @@ abstract class AppLocalizations { /// **'Why? You already have FCM.'** String get notificationsFkmAlreadyHasFcm; - /// No description provided for @notificationsFkmDownloadFcm. - /// - /// In en, this message translates to: - /// **'Better download the FCM version.'** - String get notificationsFkmDownloadFcm; - /// No description provided for @notificationsFkmIosUnsupported. /// /// In en, this message translates to: @@ -1448,6 +1442,48 @@ abstract class AppLocalizations { /// **'For FKM notifications to work, the app will need to keep a notification in the shade.'** String get notificationsFkmEnableSubtitle; + /// No description provided for @notificationsFkmUnsupported. + /// + /// In en, this message translates to: + /// **'FKM is Android-only'** + String get notificationsFkmUnsupported; + + /// No description provided for @notificationsFkmBatteryAction. + /// + /// In en, this message translates to: + /// **'Open settings'** + String get notificationsFkmBatteryAction; + + /// No description provided for @notificationsFkmBatteryMessage. + /// + /// In en, this message translates to: + /// **'Otherwise the system will put the background connection to sleep and notifications will be late or lost.'** + String get notificationsFkmBatteryMessage; + + /// No description provided for @notificationsFkmBatteryTitle. + /// + /// In en, this message translates to: + /// **'Turn off battery saving?'** + String get notificationsFkmBatteryTitle; + + /// No description provided for @notificationsFkmPermissionDenied. + /// + /// In en, this message translates to: + /// **'FKM cannot work without the notification permission'** + String get notificationsFkmPermissionDenied; + + /// No description provided for @notificationsFkmConfirmAction. + /// + /// In en, this message translates to: + /// **'Enable FKM'** + String get notificationsFkmConfirmAction; + + /// No description provided for @notificationsFkmConfirmMessage. + /// + /// In en, this message translates to: + /// **'Notifications will arrive over the app’s own background connection, and a permanent service notification will stay in the shade. You can turn FKM off right from it.'** + String get notificationsFkmConfirmMessage; + /// No description provided for @notificationsMainSectionTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 80849ea..15f141e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -696,9 +696,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationsFkmAlreadyHasFcm => 'Why? You already have FCM.'; - @override - String get notificationsFkmDownloadFcm => 'Better download the FCM version.'; - @override String get notificationsFkmIosUnsupported => 'Push notifications are not available on iOS yet.'; @@ -716,6 +713,30 @@ class AppLocalizationsEn extends AppLocalizations { String get notificationsFkmEnableSubtitle => 'For FKM notifications to work, the app will need to keep a notification in the shade.'; + @override + String get notificationsFkmUnsupported => 'FKM is Android-only'; + + @override + String get notificationsFkmBatteryAction => 'Open settings'; + + @override + String get notificationsFkmBatteryMessage => + 'Otherwise the system will put the background connection to sleep and notifications will be late or lost.'; + + @override + String get notificationsFkmBatteryTitle => 'Turn off battery saving?'; + + @override + String get notificationsFkmPermissionDenied => + 'FKM cannot work without the notification permission'; + + @override + String get notificationsFkmConfirmAction => 'Enable FKM'; + + @override + String get notificationsFkmConfirmMessage => + 'Notifications will arrive over the app’s own background connection, and a permanent service notification will stay in the shade. You can turn FKM off right from it.'; + @override String get notificationsMainSectionTitle => 'Notifications'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index b484fcc..ae9364b 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -699,10 +699,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get notificationsFkmAlreadyHasFcm => 'А зачем? У тебя уже FCM.'; - @override - String get notificationsFkmDownloadFcm => - 'Установите FCM версию с официального источника'; - @override String get notificationsFkmIosUnsupported => 'На iOS пуш-уведомления пока недоступны'; @@ -720,6 +716,30 @@ class AppLocalizationsRu extends AppLocalizations { String get notificationsFkmEnableSubtitle => 'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.'; + @override + String get notificationsFkmUnsupported => 'FKM работает только на Android'; + + @override + String get notificationsFkmBatteryAction => 'Настроить'; + + @override + String get notificationsFkmBatteryMessage => + 'Иначе система усыпит фоновое соединение, и уведомления начнут опаздывать или пропадать.'; + + @override + String get notificationsFkmBatteryTitle => 'Отключить экономию батареи?'; + + @override + String get notificationsFkmPermissionDenied => + 'Без разрешения на уведомления FKM не заработает'; + + @override + String get notificationsFkmConfirmAction => 'Включить FKM'; + + @override + String get notificationsFkmConfirmMessage => + 'Уведомления начнут приходить через собственное фоновое соединение, а в шторке будет постоянно висеть уведомление сервиса. Выключить FKM можно прямо в нём.'; + @override String get notificationsMainSectionTitle => 'Уведомления'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 82651fa..53f2603 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -236,12 +236,18 @@ "msgActionsNoText": "(без текста)", "notificationsSaveFailed": "Не удалось сохранить: {error}", "notificationsFkmAlreadyHasFcm": "А зачем? У тебя уже FCM.", - "notificationsFkmDownloadFcm": "Установите FCM версию с официального источника", "notificationsFkmIosUnsupported": "На iOS пуш-уведомления пока недоступны", "notificationsTitle": "Уведомления", "notificationsFkmSectionTitle": "FKM", "notificationsFkmEnableLabel": "Включить уведомления", "notificationsFkmEnableSubtitle": "Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.", + "notificationsFkmUnsupported": "FKM работает только на Android", + "notificationsFkmBatteryAction": "Настроить", + "notificationsFkmBatteryMessage": "Иначе система усыпит фоновое соединение, и уведомления начнут опаздывать или пропадать.", + "notificationsFkmBatteryTitle": "Отключить экономию батареи?", + "notificationsFkmPermissionDenied": "Без разрешения на уведомления FKM не заработает", + "notificationsFkmConfirmAction": "Включить FKM", + "notificationsFkmConfirmMessage": "Уведомления начнут приходить через собственное фоновое соединение, а в шторке будет постоянно висеть уведомление сервиса. Выключить FKM можно прямо в нём.", "notificationsMainSectionTitle": "Уведомления", "notificationsAllLabel": "Все уведомления", "notificationsNewSectionTitle": "Все новые уведомления", diff --git a/lib/main.dart b/lib/main.dart index baa4eaf..96c2841 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,6 +73,7 @@ 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'; +import 'core/push/fkm_controller.dart'; import 'core/push/notification_bridge.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; @@ -195,6 +196,7 @@ void main(List args) async { } attachInfoCacheApi(api); chats.attachGlobalPushHandlers(api); + unawaited(FkmController.instance.init(api)); FoldersModule.attachGlobalPushHandlers(api); TranscriptionPushHandler.attach(api); commentsModule.attachPushHandlers(api);