Handle incoming MAX call events

This commit is contained in:
Курнат Андрей
2026-07-21 15:53:32 +03:00
parent 27f9743c71
commit e59ca21c2a
8 changed files with 108 additions and 7 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax" applicationId = "xyz.kusoft.qmax"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 58 versionCode = 59
versionName = "1.0.1" versionName = "1.0.2"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
@@ -3527,6 +3527,7 @@ private fun ChatRow(
private fun cleanChatPreview(value: String?): String? { private fun cleanChatPreview(value: String?): String? {
return when { return when {
isCallMediaLabel(value) -> "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a"
isGenericMediaPreview(value) -> "\u041c\u0435\u0434\u0438\u0430" isGenericMediaPreview(value) -> "\u041c\u0435\u0434\u0438\u0430"
isQuestionMarkArtifact(value) -> "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" isQuestionMarkArtifact(value) -> "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"
else -> value else -> value
@@ -4076,6 +4077,21 @@ private fun ChatScreen(vm: QMaxViewModel) {
} }
} }
private fun isCallMediaLabel(value: String?): Boolean {
val text = value?.trim().orEmpty()
return text.equals("call", ignoreCase = true) || text.startsWith("call-", ignoreCase = true)
}
private fun displayMessageText(value: String?): String? {
return value?.takeIf { it.isNotBlank() }?.let { text ->
if (isCallMediaLabel(text)) {
"\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a"
} else {
text
}
}
}
@Composable @Composable
private fun IncomingShareDialog( private fun IncomingShareDialog(
share: IncomingShare, share: IncomingShare,
@@ -4842,7 +4858,7 @@ private fun MessageRow(
) )
} }
} }
message.text?.takeIf { it.isNotBlank() }?.let { text -> displayMessageText(message.text)?.let { text ->
LinkifiedMessageText( LinkifiedMessageText(
text = text, text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
@@ -5104,7 +5120,7 @@ private fun replyPreviewAuthor(message: MessageDto): String {
} }
private fun replyPreviewBody(message: MessageDto): String { private fun replyPreviewBody(message: MessageDto): String {
message.text?.takeIf { it.isNotBlank() }?.let { return it } displayMessageText(message.text)?.let { return it }
val attachment = message.attachments.firstOrNull() ?: return "Message" val attachment = message.attachments.firstOrNull() ?: return "Message"
return when { return when {
attachment.isEmojiAttachment() -> "Emoji" attachment.isEmojiAttachment() -> "Emoji"
@@ -6400,7 +6416,7 @@ private fun messageSortMillis(value: String): Long {
private fun MessageDto.matchesSearch(query: String): Boolean { private fun MessageDto.matchesSearch(query: String): Boolean {
val needle = query.trim().lowercase(Locale.ROOT) val needle = query.trim().lowercase(Locale.ROOT)
if (needle.isBlank()) return false if (needle.isBlank()) return false
return text?.lowercase(Locale.ROOT)?.contains(needle) == true || return displayMessageText(text)?.lowercase(Locale.ROOT)?.contains(needle) == true ||
senderName?.lowercase(Locale.ROOT)?.contains(needle) == true || senderName?.lowercase(Locale.ROOT)?.contains(needle) == true ||
attachments.any { it.fileName.lowercase(Locale.ROOT).contains(needle) } attachments.any { it.fileName.lowercase(Locale.ROOT).contains(needle) }
} }
@@ -124,12 +124,18 @@ class LocalMessageCache(context: Context) {
private fun cleanChat(chat: ChatDto): ChatDto { private fun cleanChat(chat: ChatDto): ChatDto {
return when { return when {
isCallMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a")
isGenericMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u041c\u0435\u0434\u0438\u0430") isGenericMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u041c\u0435\u0434\u0438\u0430")
isQuestionMarkArtifact(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435") isQuestionMarkArtifact(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435")
else -> chat else -> chat
} }
} }
private fun isCallMediaLabel(value: String?): Boolean {
val text = value?.trim().orEmpty()
return text.equals("call", ignoreCase = true) || text.startsWith("call-", ignoreCase = true)
}
private fun isGenericMediaPlaceholder(message: MessageDto): Boolean { private fun isGenericMediaPlaceholder(message: MessageDto): Boolean {
return message.attachments.isEmpty() && isGenericMediaLabel(message.text) return message.attachments.isEmpty() && isGenericMediaLabel(message.text)
} }
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Any
INCOMING_CALL_TEXT = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a"
def is_call_media_label(value: Any) -> bool:
if value is None:
return False
text = str(value).strip().lower()
return text == "call" or text.startswith("call-")
+12 -2
View File
@@ -22,6 +22,8 @@ from aiohttp import web
from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video
from pymax.types import ContactInfo from pymax.types import ContactInfo
from .message_labels import INCOMING_CALL_TEXT, is_call_media_label
PORT = int(os.environ.get("PORT", "3002")) PORT = int(os.environ.get("PORT", "3002"))
PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or "" PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or ""
@@ -581,6 +583,8 @@ def normalize_attachment_kind(att: Any) -> str:
raw_type = value_name(data.get("type") or data.get("_type") or getattr(att, "type", "")).lower() raw_type = value_name(data.get("type") or data.get("_type") or getattr(att, "type", "")).lower()
class_name = type(att).__name__.lower() class_name = type(att).__name__.lower()
merged = f"{raw_type} {class_name}" merged = f"{raw_type} {class_name}"
if is_call_media_label(raw_type) or "callattachment" in class_name:
return "call"
if "photo" in merged: if "photo" in merged:
return "photo" return "photo"
if "video" in merged: if "video" in merged:
@@ -600,6 +604,8 @@ def attachment_preview(attaches: list[Any]) -> str | None:
if not attaches: if not attaches:
return None return None
kinds = [normalize_attachment_kind(att) for att in attaches] kinds = [normalize_attachment_kind(att) for att in attaches]
if any(is_call_media_label(kind) for kind in kinds):
return INCOMING_CALL_TEXT
if any(kind == "photo" for kind in kinds): if any(kind == "photo" for kind in kinds):
return "\u0424\u043e\u0442\u043e" return "\u0424\u043e\u0442\u043e"
if any(kind == "video" for kind in kinds): if any(kind == "video" for kind in kinds):
@@ -731,16 +737,20 @@ async def normalize_message(
me_id: int | None = None, me_id: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
attaches = list(getattr(message, "attaches", None) or []) attaches = list(getattr(message, "attaches", None) or [])
call_attaches = [att for att in attaches if normalize_attachment_kind(att) == "call"]
downloadable_attaches = [att for att in attaches if normalize_attachment_kind(att) != "call"]
attachments = [ attachments = [
await normalize_attachment(client, message, att, index) await normalize_attachment(client, message, att, index)
for index, att in enumerate(attaches) for index, att in enumerate(downloadable_attaches)
] ]
sender = getattr(message, "sender", None) sender = getattr(message, "sender", None)
sender_id = coerce_int(sender) sender_id = coerce_int(sender)
if me_id is None: if me_id is None:
me_id = get_me_user_id(client) me_id = get_me_user_id(client)
is_outgoing = sender_id is not None and me_id is not None and sender_id == me_id is_outgoing = sender_id is not None and me_id is not None and sender_id == me_id
text = getattr(message, "text", None) or attachment_preview(attaches) raw_text = getattr(message, "text", None)
is_call_text = is_call_media_label(raw_text)
text = INCOMING_CALL_TEXT if call_attaches or is_call_text else raw_text or attachment_preview(attaches)
status = value_name(getattr(message, "status", None)).lower() or None status = value_name(getattr(message, "status", None)).lower() or None
user = (user_map or {}).get(str(sender_id)) if sender_id is not None else None user = (user_map or {}).get(str(sender_id)) if sender_id is not None else None
sender_name = "You" if is_outgoing else user_display_name(user) sender_name = "You" if is_outgoing else user_display_name(user)
+22
View File
@@ -0,0 +1,22 @@
import unittest
from src.message_labels import INCOMING_CALL_TEXT, is_call_media_label
class MessageLabelsTests(unittest.TestCase):
def test_call_media_labels_are_detected(self) -> None:
for value in ("call", "call-start", "CALL-MISSED", " call-ended "):
with self.subTest(value=value):
self.assertTrue(is_call_media_label(value))
def test_unrelated_values_are_not_detected(self) -> None:
for value in (None, "", "callback", "video-call", "phone"):
with self.subTest(value=value):
self.assertFalse(is_call_media_label(value))
def test_incoming_call_text_is_stable(self) -> None:
self.assertEqual("\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a", INCOMING_CALL_TEXT)
if __name__ == "__main__":
unittest.main()
@@ -2,8 +2,15 @@ namespace QMax.Api.Services;
public static class MessageTextSanitizer public static class MessageTextSanitizer
{ {
private const string IncomingCallText = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a";
public static string? CleanChatPreview(string? value) public static string? CleanChatPreview(string? value)
{ {
if (IsCallMediaLabel(value))
{
return IncomingCallText;
}
if (IsGenericMediaLabel(value)) if (IsGenericMediaLabel(value))
{ {
return "\u041c\u0435\u0434\u0438\u0430"; return "\u041c\u0435\u0434\u0438\u0430";
@@ -16,6 +23,11 @@ public static class MessageTextSanitizer
public static string? CleanMessageText(string? value, bool hasAttachments) public static string? CleanMessageText(string? value, bool hasAttachments)
{ {
if (IsCallMediaLabel(value))
{
return IncomingCallText;
}
if (hasAttachments && IsGenericAttachmentLabel(value)) if (hasAttachments && IsGenericAttachmentLabel(value))
{ {
return null; return null;
@@ -60,6 +72,13 @@ public static class MessageTextSanitizer
}; };
} }
public static bool IsCallMediaLabel(string? value)
{
var text = value?.Trim();
return string.Equals(text, "call", StringComparison.OrdinalIgnoreCase) ||
text?.StartsWith("call-", StringComparison.OrdinalIgnoreCase) == true;
}
public static bool IsGenericAttachmentLabel(string? value) public static bool IsGenericAttachmentLabel(string? value)
{ {
if (IsGenericMediaLabel(value)) if (IsGenericMediaLabel(value))
+15
View File
@@ -1676,6 +1676,21 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????")); Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????"));
} }
[Theory]
[InlineData("call-start")]
[InlineData("CALL-MISSED")]
[InlineData(" call-ended ")]
[InlineData("call")]
public void MessageTextSanitizerLabelsCallMediaAsIncomingCall(string value)
{
const string expected = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a";
Assert.True(MessageTextSanitizer.IsCallMediaLabel(value));
Assert.Equal(expected, MessageTextSanitizer.CleanChatPreview(value));
Assert.Equal(expected, MessageTextSanitizer.CleanMessageText(value, hasAttachments: false));
Assert.Equal(expected, MessageTextSanitizer.CleanMessageText(value, hasAttachments: true));
}
[Fact] [Fact]
public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingMessage() public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingMessage()
{ {