feat: ОЛО СЫН ЕСТ КАМНИ
This commit is contained in:
@@ -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<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() {
|
||||
channel?.setMethodCallHandler(null)
|
||||
channel = null
|
||||
|
||||
@@ -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<String, String>) {
|
||||
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>) {
|
||||
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<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 personCache = HashMap<String, Person>()
|
||||
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<Hist> {
|
||||
val prefs = pushPrefs()
|
||||
val key = "hist_$chatId"
|
||||
private fun loadHistory(chatId: Long): List<Hist> {
|
||||
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<Hist>(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<Hist>) {
|
||||
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 {
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
<string name="fkm_status_line">%1$s · принято %2$d</string>
|
||||
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
|
||||
<string name="fkm_disable">Выключить</string>
|
||||
<string name="notif_deleted_prefix">Удалено:</string>
|
||||
</resources>
|
||||
|
||||
@@ -10,4 +10,5 @@
|
||||
<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_disable">Turn off</string>
|
||||
<string name="notif_deleted_prefix">Deleted:</string>
|
||||
</resources>
|
||||
|
||||
@@ -50,6 +50,12 @@ class FkmBridge {
|
||||
Future<void> showCall(Map<String, String> 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 {
|
||||
if (!isSupported) return false;
|
||||
return await _invoke<bool>('hasNotificationPermission') ?? false;
|
||||
|
||||
@@ -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<void> _onMessagePush(Packet packet) async {
|
||||
Future<void> _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<Map<String, String>?> _buildNotification(Packet packet) async {
|
||||
Future<void> _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<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'];
|
||||
if (senderId is! int) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user