From 6ce3ddce30143c45e61e6e564628f01347061034 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 22 Aug 2026 00:11:38 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9E=D0=9B=D0=9E=20=D0=A1=D0=AB=D0=9D?= =?UTF-8?q?=20=D0=95=D0=A1=D0=A2=20=D0=9A=D0=90=D0=9C=D0=9D=D0=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/ru/komet/app/FkmChannel.kt | 30 +++ .../kotlin/ru/komet/app/KometFcmService.kt | 224 +++++++++++++++--- .../app/src/main/res/values-ru/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + lib/core/push/fkm_bridge.dart | 6 + lib/core/push/fkm_controller.dart | 102 +++++++- 6 files changed, 317 insertions(+), 47 deletions(-) diff --git a/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt b/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt index d5f0e7c..5638a48 100644 --- a/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt +++ b/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt @@ -55,6 +55,18 @@ object FkmChannel { "showCall" -> result.success(deliver(ctx, call, "showCall")) + "editMessage" -> result.success( + update(ctx, call, "editMessage") { notifier, data -> + notifier.editMessage(data) + }, + ) + + "removeMessage" -> result.success( + update(ctx, call, "removeMessage") { notifier, data -> + notifier.removeMessage(data) + }, + ) + "hasNotificationPermission" -> result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled()) @@ -115,6 +127,24 @@ object FkmChannel { return true } + // Правка и удаление ничего не «доставляют» — счётчик они не трогают. + private fun update( + ctx: Context, + call: MethodCall, + tag: String, + action: (KometNotifier, Map) -> Unit, + ): Boolean { + val data = call.argument>("data") ?: return false + worker.execute { + try { + action(KometNotifier(ctx), data) + } catch (e: Exception) { + Log.w("Fkm", "$tag failed: ${e.message}") + } + } + return true + } + fun detach() { channel?.setMethodCallHandler(null) channel = null 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 e65d1e9..0cb9c8f 100644 --- a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt @@ -11,6 +11,7 @@ import android.graphics.Typeface import android.os.Build import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.util.Log import androidx.core.app.NotificationCompat @@ -57,7 +58,18 @@ class KometNotifier(private val ctx: Context) { private const val ACCENT = 0xFF7C6BF0.toInt() } - private data class Hist(val text: String, val key: String, val name: String, val ts: Long) + private data class Hist( + val text: String, + val key: String, + val name: String, + val ts: Long, + val mid: String, + val deleted: Boolean, + ) + + // Что нужно для перерисовки чата, когда нового пуша нет: правка и удаление + // приходят без заголовка, аккаунта и признака группы. + private data class ChatMeta(val title: String, val account: Int, val group: Boolean) fun handle(data: Map) { when (data["type"]) { @@ -79,13 +91,15 @@ class KometNotifier(private val ctx: Context) { } } + private fun notifIdOf(chatId: Long): Int = (chatId and 0x7fffffff).toInt() + private fun showMessage(data: Map) { val chatId = data["mc"]?.toLongOrNull() ?: return - val notifId = (chatId and 0x7fffffff).toInt() + val notifId = notifIdOf(chatId) if (ChatNotifications.isDisplayed(chatId)) { manager().cancel(notifId) - clearHistory(chatId) - rebuildSummary(notifId) + clearChat(chatId) + syncSummary(notifId, null) return } val senderId = data["suid"] ?: "" @@ -94,14 +108,90 @@ class KometNotifier(private val ctx: Context) { val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение" val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull() ?: System.currentTimeMillis() - val isGroup = chatTitle != senderName ensureChannel() val active = activeIds() - if (!active.contains(notifId)) clearHistory(chatId) - val history = appendHistory(chatId, Hist(text, senderId, senderName, ts)) + if (!active.contains(notifId)) clearChat(chatId) + val meta = ChatMeta(chatTitle, data["c"]?.toIntOrNull() ?: 0, chatTitle != senderName) + saveMeta(chatId, meta) + val history = appendHistory( + chatId, + Hist(text, senderId, senderName, ts, data["msgid"] ?: "", false), + ) + render(chatId, notifId, meta, history, alertOnce = false) + updateSummary(notifId, senderName, text, ts, active) + } + + // Сообщение отредактировали — правим текст в уже висящем уведомлении. + fun editMessage(data: Map) { + val chatId = data["mc"]?.toLongOrNull() ?: return + val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return + val text = data["msg"]?.takeIf { it.isNotEmpty() } ?: return + + val history = loadHistory(chatId) + val index = history.indexOfLast { it.mid == mid } + if (index < 0) return + val old = history[index] + if (old.deleted || old.text == text) return + + val updated = history.toMutableList() + updated[index] = old.copy(text = text) + saveHistory(chatId, updated) + + val notifId = notifIdOf(chatId) + if (!activeIds().contains(notifId)) return + val meta = loadMeta(chatId) ?: return + render(chatId, notifId, meta, updated, alertOnce = true) + syncSummary(notifId, updated.last()) + } + + // Сообщение удалили. keep — включено «показывать удалённые сообщения»: + // тогда строка остаётся в шторке, но зачёркнутой и с пометкой. + fun removeMessage(data: Map) { + val chatId = data["mc"]?.toLongOrNull() ?: return + val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return + val keep = data["keep"] == "true" + + val history = loadHistory(chatId) + val index = history.indexOfLast { it.mid == mid } + if (index < 0) return + if (keep && history[index].deleted) return + + val updated = history.toMutableList() + if (keep) { + updated[index] = updated[index].copy(deleted = true) + } else { + updated.removeAt(index) + } + + val notifId = notifIdOf(chatId) + if (updated.isEmpty()) { + clearChat(chatId) + manager().cancel(notifId) + syncSummary(notifId, null) + return + } + + saveHistory(chatId, updated) + if (!activeIds().contains(notifId)) return + val meta = loadMeta(chatId) ?: return + render(chatId, notifId, meta, updated, alertOnce = true) + syncSummary(notifId, updated.last()) + } + + private fun render( + chatId: Long, + notifId: Int, + meta: ChatMeta, + history: List, + alertOnce: Boolean, + ) { + if (history.isEmpty()) return + ensureChannel() + + val newest = history.last() val avatarCache = HashMap() val personCache = HashMap() fun avatarFor(key: String, name: String): Bitmap = @@ -115,17 +205,16 @@ class KometNotifier(private val ctx: Context) { .build() } - val senderPerson = personFor(senderId, senderName) val shortcutId = "chat_$chatId" - publishShortcut(shortcutId, chatId, chatTitle, senderPerson) + publishShortcut(shortcutId, chatId, meta.title, personFor(newest.key, newest.name)) val style = NotificationCompat.MessagingStyle(Person.Builder().setName("Вы").build()) - if (isGroup) { - style.conversationTitle = chatTitle + if (meta.group) { + style.conversationTitle = meta.title style.isGroupConversation = true } for (h in history) { - style.addMessage(h.text, h.ts, personFor(h.key, h.name)) + style.addMessage(displayText(h), h.ts, personFor(h.key, h.name)) } val builder = NotificationCompat.Builder(ctx, CHANNEL_ID) @@ -134,23 +223,36 @@ class KometNotifier(private val ctx: Context) { .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setAutoCancel(true) .setGroup(GROUP_KEY) - .setWhen(ts) + .setWhen(newest.ts) .setShowWhen(true) + .setOnlyAlertOnce(alertOnce) .setContentIntent(openIntent(notifId, chatId)) .setShortcutId(shortcutId) .setLocusId(LocusIdCompat(shortcutId)) .setStyle(style) - .setLargeIcon(avatarFor(senderId, senderName)) + .setLargeIcon(avatarFor(newest.key, newest.name)) - val account = data["c"]?.toIntOrNull() ?: 0 - if (account != 0) { - builder.addAction(replyAction(notifId, account, chatId, data["msgid"]?.toLongOrNull())) + if (meta.account != 0) { + val replyTo = history.lastOrNull { !it.deleted }?.mid?.toLongOrNull() + builder.addAction(replyAction(notifId, meta.account, chatId, replyTo)) } manager().notify(notifId, builder.build()) - updateSummary(notifId, senderName, text, ts, active) } + private fun displayText(h: Hist): CharSequence { + if (!h.deleted) return h.text + val sb = SpannableStringBuilder(ctx.getString(R.string.notif_deleted_prefix)) + sb.append(' ') + val start = sb.length + sb.append(h.text) + sb.setSpan(StrikethroughSpan(), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + return sb + } + + private fun plainText(h: Hist): String = + if (h.deleted) "${ctx.getString(R.string.notif_deleted_prefix)} ${h.text}" else h.text + private fun updateSummary( notifId: Int, senderName: String, @@ -176,7 +278,8 @@ class KometNotifier(private val ctx: Context) { publishSummary(entriesOf(kept)) } - private fun rebuildSummary(dismissedId: Int) { + // `newest` == null — уведомление чата ушло из шторки. + private fun syncSummary(notifId: Int, newest: Hist?) { val active = activeIds() val reg = loadRegistry() val kept = JSONObject() @@ -184,10 +287,19 @@ class KometNotifier(private val ctx: Context) { while (keys.hasNext()) { val k = keys.next() val id = k.toIntOrNull() ?: continue - if (id == dismissedId) continue + if (id == notifId) continue val entry = reg.optJSONObject(k) ?: continue if (active.contains(id)) kept.put(k, entry) } + if (newest != null && active.contains(notifId)) { + kept.put( + notifId.toString(), + JSONObject() + .put("n", newest.name) + .put("t", plainText(newest)) + .put("ts", newest.ts), + ) + } saveRegistry(kept) publishSummary(entriesOf(kept)) } @@ -321,30 +433,74 @@ class KometNotifier(private val ctx: Context) { private fun pushPrefs() = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private fun appendHistory(chatId: Long, item: Hist): List { - val prefs = pushPrefs() - val key = "hist_$chatId" + private fun loadHistory(chatId: Long): List { val arr = try { - JSONArray(prefs.getString(key, "[]")) + JSONArray(pushPrefs().getString("hist_$chatId", "[]")) } catch (e: Exception) { JSONArray() } - arr.put( - JSONObject().put("t", item.text).put("k", item.key) - .put("n", item.name).put("ts", item.ts), - ) - while (arr.length() > HISTORY_LIMIT) arr.remove(0) - prefs.edit().putString(key, arr.toString()).apply() val out = ArrayList(arr.length()) for (i in 0 until arr.length()) { - val o = arr.getJSONObject(i) - out.add(Hist(o.optString("t"), o.optString("k"), o.optString("n"), o.optLong("ts"))) + val o = arr.optJSONObject(i) ?: continue + out.add( + Hist( + o.optString("t"), + o.optString("k"), + o.optString("n"), + o.optLong("ts"), + o.optString("m"), + o.optBoolean("d"), + ), + ) } return out } - private fun clearHistory(chatId: Long) { - pushPrefs().edit().remove("hist_$chatId").apply() + private fun saveHistory(chatId: Long, items: List) { + val arr = JSONArray() + for (h in items) { + arr.put( + JSONObject() + .put("t", h.text) + .put("k", h.key) + .put("n", h.name) + .put("ts", h.ts) + .put("m", h.mid) + .put("d", h.deleted), + ) + } + pushPrefs().edit().putString("hist_$chatId", arr.toString()).apply() + } + + private fun appendHistory(chatId: Long, item: Hist): List { + val items = ArrayList(loadHistory(chatId)) + items.add(item) + while (items.size > HISTORY_LIMIT) items.removeAt(0) + saveHistory(chatId, items) + return items + } + + private fun clearChat(chatId: Long) { + pushPrefs().edit().remove("hist_$chatId").remove("meta_$chatId").apply() + } + + private fun saveMeta(chatId: Long, meta: ChatMeta) { + val json = JSONObject() + .put("t", meta.title) + .put("a", meta.account) + .put("g", meta.group) + .toString() + pushPrefs().edit().putString("meta_$chatId", json).apply() + } + + private fun loadMeta(chatId: Long): ChatMeta? { + val raw = pushPrefs().getString("meta_$chatId", null) ?: return null + return try { + val o = JSONObject(raw) + ChatMeta(o.optString("t"), o.optInt("a"), o.optBoolean("g")) + } catch (e: Exception) { + null + } } private fun loadRegistry(): JSONObject = try { diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index dbd28c8..cd6cb9b 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -8,4 +8,5 @@ %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 82ae05a..862884a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,4 +10,5 @@ %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 + Deleted: diff --git a/lib/core/push/fkm_bridge.dart b/lib/core/push/fkm_bridge.dart index d8b4a97..54bfe3e 100644 --- a/lib/core/push/fkm_bridge.dart +++ b/lib/core/push/fkm_bridge.dart @@ -50,6 +50,12 @@ class FkmBridge { Future showCall(Map data) => _invoke('showCall', {'data': data}); + Future editMessage(Map data) => + _invoke('editMessage', {'data': data}); + + Future removeMessage(Map data) => + _invoke('removeMessage', {'data': data}); + Future hasNotificationPermission() async { if (!isSupported) return false; return await _invoke('hasNotificationPermission') ?? false; diff --git a/lib/core/push/fkm_controller.dart b/lib/core/push/fkm_controller.dart index f0ff1d9..a315ea5 100644 --- a/lib/core/push/fkm_controller.dart +++ b/lib/core/push/fkm_controller.dart @@ -11,6 +11,7 @@ import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; +import '../config/komet_settings.dart'; import '../utils/logger.dart'; import 'fkm_bridge.dart'; import 'push_service.dart'; @@ -44,8 +45,12 @@ class FkmController { enabled.value = await FkmBridge.instance.isEnabled(); _pushSub = api.pushStream - .where((packet) => packet.opcode == Opcode.notifMessage) - .listen(_onMessagePush); + .where( + (packet) => + packet.opcode == Opcode.notifMessage || + packet.opcode == Opcode.notifMsgDelete, + ) + .listen(_onPush); _stateSub = api.stateStream.listen(_onSessionState); if (enabled.value) { @@ -127,31 +132,102 @@ class FkmController { }; } - Future _onMessagePush(Packet packet) async { + Future _onPush(Packet packet) async { if (!enabled.value) return; try { - final data = await _buildNotification(packet); - if (data != null) await FkmBridge.instance.showMessage(data); + if (packet.opcode == Opcode.notifMsgDelete) { + await _onDeletePush(packet); + } else { + await _onMessagePush(packet); + } } catch (e) { - logger.w('FKM: не удалось показать уведомление: $e'); + logger.w('FKM: не удалось обновить уведомления: $e'); } } - Future?> _buildNotification(Packet packet) async { + Future _onMessagePush(Packet packet) async { final payload = packet.payload; - if (payload is! Map) return null; + if (payload is! Map) return; final chatId = payload['chatId']; - if (chatId is! int) return null; + if (chatId is! int) return; final msg = payload['message']; - if (msg is! Map) return null; + if (msg is! Map) return; - if (payload['postId'] != null || msg['postId'] != null) return null; + if (payload['postId'] != null || msg['postId'] != null) return; - final status = msg['status']?.toString(); - if (status == 'REMOVED' || status == 'EDITED') return null; + final msgId = msg['id']?.toString(); + switch (msg['status']?.toString()) { + case 'REMOVED': + if (msgId != null) await _removeNotification(chatId, msgId); + return; + case 'EDITED': + if (msgId != null) await _editNotification(chatId, msgId, msg); + return; + } + final data = await _buildNotification(chatId, msg); + if (data != null) await FkmBridge.instance.showMessage(data); + } + + Future _onDeletePush(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + + final chat = payload['chat']; + final chatId = (chat is Map && chat['id'] is int) + ? chat['id'] as int + : payload['chatId']; + if (chatId is! int) return; + + final ids = payload['messageIds']; + if (ids is! List) return; + for (final raw in ids) { + final id = raw?.toString(); + if (id == null || id.isEmpty) continue; + await _removeNotification(chatId, id); + } + } + + /// Удалённое сообщение уезжает из шторки, а при включённом «показывать + /// удалённые сообщения» остаётся в ней зачёркнутым. + Future _removeNotification(int chatId, String msgId) => + FkmBridge.instance.removeMessage({ + 'mc': '$chatId', + 'msgid': msgId, + 'keep': KometSettings.viewDeleted.value ? 'true' : 'false', + }); + + Future _editNotification( + int chatId, + String msgId, + Map msg, + ) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final rawConfig = await AppDatabase.getPrivacyConfig(accountId); + if (rawConfig != null) { + final config = PrivacyConfig.fromJson(rawConfig); + if (config.chatsPushNotification != 'ON') return; + if (!config.pushDetails) return; + } + + final text = _previewText(msg); + if (text == _hiddenPreview) return; + + await FkmBridge.instance.editMessage({ + 'mc': '$chatId', + 'msgid': msgId, + 'msg': text, + }); + } + + Future?> _buildNotification( + int chatId, + Map msg, + ) async { final senderId = msg['sender']; if (senderId is! int) return null;