feat: ОЛО СЫН ЕСТ КАМНИ

This commit is contained in:
Jganenokk
2026-08-22 00:11:38 +07:00
parent eb94461c04
commit 6ce3ddce30
6 changed files with 317 additions and 47 deletions
@@ -55,6 +55,18 @@ object FkmChannel {
"showCall" -> result.success(deliver(ctx, call, "showCall")) "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" -> "hasNotificationPermission" ->
result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled()) result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled())
@@ -115,6 +127,24 @@ object FkmChannel {
return true return true
} }
// Правка и удаление ничего не «доставляют» — счётчик они не трогают.
private fun update(
ctx: Context,
call: MethodCall,
tag: String,
action: (KometNotifier, Map<String, String>) -> Unit,
): Boolean {
val data = call.argument<Map<String, String>>("data") ?: return false
worker.execute {
try {
action(KometNotifier(ctx), data)
} catch (e: Exception) {
Log.w("Fkm", "$tag failed: ${e.message}")
}
}
return true
}
fun detach() { fun detach() {
channel?.setMethodCallHandler(null) channel?.setMethodCallHandler(null)
channel = null channel = null
@@ -11,6 +11,7 @@ import android.graphics.Typeface
import android.os.Build import android.os.Build
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@@ -57,7 +58,18 @@ class KometNotifier(private val ctx: Context) {
private const val ACCENT = 0xFF7C6BF0.toInt() 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<String, String>) { fun handle(data: Map<String, String>) {
when (data["type"]) { 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<String, String>) { private fun showMessage(data: Map<String, String>) {
val chatId = data["mc"]?.toLongOrNull() ?: return val chatId = data["mc"]?.toLongOrNull() ?: return
val notifId = (chatId and 0x7fffffff).toInt() val notifId = notifIdOf(chatId)
if (ChatNotifications.isDisplayed(chatId)) { if (ChatNotifications.isDisplayed(chatId)) {
manager().cancel(notifId) manager().cancel(notifId)
clearHistory(chatId) clearChat(chatId)
rebuildSummary(notifId) syncSummary(notifId, null)
return return
} }
val senderId = data["suid"] ?: "" val senderId = data["suid"] ?: ""
@@ -94,14 +108,90 @@ class KometNotifier(private val ctx: Context) {
val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение" val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение"
val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull() val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull()
?: System.currentTimeMillis() ?: System.currentTimeMillis()
val isGroup = chatTitle != senderName
ensureChannel() ensureChannel()
val active = activeIds() val active = activeIds()
if (!active.contains(notifId)) clearHistory(chatId) if (!active.contains(notifId)) clearChat(chatId)
val history = appendHistory(chatId, Hist(text, senderId, senderName, ts)) 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<String, String>) {
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<String, String>) {
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<Hist>,
alertOnce: Boolean,
) {
if (history.isEmpty()) return
ensureChannel()
val newest = history.last()
val avatarCache = HashMap<String, Bitmap>() val avatarCache = HashMap<String, Bitmap>()
val personCache = HashMap<String, Person>() val personCache = HashMap<String, Person>()
fun avatarFor(key: String, name: String): Bitmap = fun avatarFor(key: String, name: String): Bitmap =
@@ -115,17 +205,16 @@ class KometNotifier(private val ctx: Context) {
.build() .build()
} }
val senderPerson = personFor(senderId, senderName)
val shortcutId = "chat_$chatId" 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()) val style = NotificationCompat.MessagingStyle(Person.Builder().setName("Вы").build())
if (isGroup) { if (meta.group) {
style.conversationTitle = chatTitle style.conversationTitle = meta.title
style.isGroupConversation = true style.isGroupConversation = true
} }
for (h in history) { 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) val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
@@ -134,23 +223,36 @@ class KometNotifier(private val ctx: Context) {
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true) .setAutoCancel(true)
.setGroup(GROUP_KEY) .setGroup(GROUP_KEY)
.setWhen(ts) .setWhen(newest.ts)
.setShowWhen(true) .setShowWhen(true)
.setOnlyAlertOnce(alertOnce)
.setContentIntent(openIntent(notifId, chatId)) .setContentIntent(openIntent(notifId, chatId))
.setShortcutId(shortcutId) .setShortcutId(shortcutId)
.setLocusId(LocusIdCompat(shortcutId)) .setLocusId(LocusIdCompat(shortcutId))
.setStyle(style) .setStyle(style)
.setLargeIcon(avatarFor(senderId, senderName)) .setLargeIcon(avatarFor(newest.key, newest.name))
val account = data["c"]?.toIntOrNull() ?: 0 if (meta.account != 0) {
if (account != 0) { val replyTo = history.lastOrNull { !it.deleted }?.mid?.toLongOrNull()
builder.addAction(replyAction(notifId, account, chatId, data["msgid"]?.toLongOrNull())) builder.addAction(replyAction(notifId, meta.account, chatId, replyTo))
} }
manager().notify(notifId, builder.build()) 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( private fun updateSummary(
notifId: Int, notifId: Int,
senderName: String, senderName: String,
@@ -176,7 +278,8 @@ class KometNotifier(private val ctx: Context) {
publishSummary(entriesOf(kept)) publishSummary(entriesOf(kept))
} }
private fun rebuildSummary(dismissedId: Int) { // `newest` == null — уведомление чата ушло из шторки.
private fun syncSummary(notifId: Int, newest: Hist?) {
val active = activeIds() val active = activeIds()
val reg = loadRegistry() val reg = loadRegistry()
val kept = JSONObject() val kept = JSONObject()
@@ -184,10 +287,19 @@ class KometNotifier(private val ctx: Context) {
while (keys.hasNext()) { while (keys.hasNext()) {
val k = keys.next() val k = keys.next()
val id = k.toIntOrNull() ?: continue val id = k.toIntOrNull() ?: continue
if (id == dismissedId) continue if (id == notifId) continue
val entry = reg.optJSONObject(k) ?: continue val entry = reg.optJSONObject(k) ?: continue
if (active.contains(id)) kept.put(k, entry) 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) saveRegistry(kept)
publishSummary(entriesOf(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 pushPrefs() = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun appendHistory(chatId: Long, item: Hist): List<Hist> { private fun loadHistory(chatId: Long): List<Hist> {
val prefs = pushPrefs()
val key = "hist_$chatId"
val arr = try { val arr = try {
JSONArray(prefs.getString(key, "[]")) JSONArray(pushPrefs().getString("hist_$chatId", "[]"))
} catch (e: Exception) { } catch (e: Exception) {
JSONArray() 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<Hist>(arr.length()) val out = ArrayList<Hist>(arr.length())
for (i in 0 until arr.length()) { for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i) val o = arr.optJSONObject(i) ?: continue
out.add(Hist(o.optString("t"), o.optString("k"), o.optString("n"), o.optLong("ts"))) out.add(
Hist(
o.optString("t"),
o.optString("k"),
o.optString("n"),
o.optLong("ts"),
o.optString("m"),
o.optBoolean("d"),
),
)
} }
return out return out
} }
private fun clearHistory(chatId: Long) { private fun saveHistory(chatId: Long, items: List<Hist>) {
pushPrefs().edit().remove("hist_$chatId").apply() 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<Hist> {
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 { private fun loadRegistry(): JSONObject = try {
@@ -8,4 +8,5 @@
<string name="fkm_status_line">%1$s · принято %2$d</string> <string name="fkm_status_line">%1$s · принято %2$d</string>
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string> <string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
<string name="fkm_disable">Выключить</string> <string name="fkm_disable">Выключить</string>
<string name="notif_deleted_prefix">Удалено:</string>
</resources> </resources>
@@ -10,4 +10,5 @@
<string name="fkm_status_line">%1$s · %2$d delivered</string> <string name="fkm_status_line">%1$s · %2$d delivered</string>
<string name="fkm_explain">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.</string> <string name="fkm_explain">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.</string>
<string name="fkm_disable">Turn off</string> <string name="fkm_disable">Turn off</string>
<string name="notif_deleted_prefix">Deleted:</string>
</resources> </resources>
+6
View File
@@ -50,6 +50,12 @@ class FkmBridge {
Future<void> showCall(Map<String, String> data) => Future<void> showCall(Map<String, String> data) =>
_invoke<void>('showCall', {'data': data}); _invoke<void>('showCall', {'data': data});
Future<void> editMessage(Map<String, String> data) =>
_invoke<void>('editMessage', {'data': data});
Future<void> removeMessage(Map<String, String> data) =>
_invoke<void>('removeMessage', {'data': data});
Future<bool> hasNotificationPermission() async { Future<bool> hasNotificationPermission() async {
if (!isSupported) return false; if (!isSupported) return false;
return await _invoke<bool>('hasNotificationPermission') ?? false; return await _invoke<bool>('hasNotificationPermission') ?? false;
+89 -13
View File
@@ -11,6 +11,7 @@ import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart'; import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart'; import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart'; import '../../core/storage/token_storage.dart';
import '../config/komet_settings.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
import 'fkm_bridge.dart'; import 'fkm_bridge.dart';
import 'push_service.dart'; import 'push_service.dart';
@@ -44,8 +45,12 @@ class FkmController {
enabled.value = await FkmBridge.instance.isEnabled(); enabled.value = await FkmBridge.instance.isEnabled();
_pushSub = api.pushStream _pushSub = api.pushStream
.where((packet) => packet.opcode == Opcode.notifMessage) .where(
.listen(_onMessagePush); (packet) =>
packet.opcode == Opcode.notifMessage ||
packet.opcode == Opcode.notifMsgDelete,
)
.listen(_onPush);
_stateSub = api.stateStream.listen(_onSessionState); _stateSub = api.stateStream.listen(_onSessionState);
if (enabled.value) { if (enabled.value) {
@@ -127,31 +132,102 @@ class FkmController {
}; };
} }
Future<void> _onMessagePush(Packet packet) async { Future<void> _onPush(Packet packet) async {
if (!enabled.value) return; if (!enabled.value) return;
try { try {
final data = await _buildNotification(packet); if (packet.opcode == Opcode.notifMsgDelete) {
if (data != null) await FkmBridge.instance.showMessage(data); await _onDeletePush(packet);
} else {
await _onMessagePush(packet);
}
} catch (e) { } catch (e) {
logger.w('FKM: не удалось показать уведомление: $e'); logger.w('FKM: не удалось обновить уведомления: $e');
} }
} }
Future<Map<String, String>?> _buildNotification(Packet packet) async { Future<void> _onMessagePush(Packet packet) async {
final payload = packet.payload; final payload = packet.payload;
if (payload is! Map) return null; if (payload is! Map) return;
final chatId = payload['chatId']; final chatId = payload['chatId'];
if (chatId is! int) return null; if (chatId is! int) return;
final msg = payload['message']; 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(); final msgId = msg['id']?.toString();
if (status == 'REMOVED' || status == 'EDITED') return null; 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<void> _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<void> _removeNotification(int chatId, String msgId) =>
FkmBridge.instance.removeMessage({
'mc': '$chatId',
'msgid': msgId,
'keep': KometSettings.viewDeleted.value ? 'true' : 'false',
});
Future<void> _editNotification(
int chatId,
String msgId,
Map<dynamic, dynamic> 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<Map<String, String>?> _buildNotification(
int chatId,
Map<dynamic, dynamic> msg,
) async {
final senderId = msg['sender']; final senderId = msg['sender'];
if (senderId is! int) return null; if (senderId is! int) return null;