Handle incoming MAX call events
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user