Fix media notifications and photo bubbles

This commit is contained in:
sevenhill
2026-07-06 13:17:47 +03:00
parent 61d7aae7eb
commit 30361370da
4 changed files with 78 additions and 18 deletions
@@ -3,6 +3,7 @@ package xyz.kusoft.qmax
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaRecorder import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -157,8 +158,10 @@ import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatDto
@@ -2226,7 +2229,8 @@ private fun MessageRow(
attachments = visualAttachments, attachments = visualAttachments,
session = session, session = session,
vm = vm, vm = vm,
imagePreviewSources = imagePreviewSources imagePreviewSources = imagePreviewSources,
mediaShape = shape
) )
} else { } else {
sortedAttachments.forEach { attachment -> sortedAttachments.forEach { attachment ->
@@ -2234,7 +2238,8 @@ private fun MessageRow(
attachment = attachment, attachment = attachment,
session = session, session = session,
vm = vm, vm = vm,
imagePreviewSources = imagePreviewSources imagePreviewSources = imagePreviewSources,
mediaShape = shape
) )
} }
} }
@@ -2514,13 +2519,16 @@ private fun MediaAlbumGrid(
attachments: List<AttachmentDto>, attachments: List<AttachmentDto>,
session: QMaxSession, session: QMaxSession,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
val visible = attachments.take(4) val visible = attachments.take(4)
val extraCount = (attachments.size - visible.size).coerceAtLeast(0) val extraCount = (attachments.size - visible.size).coerceAtLeast(0)
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clip(mediaShape),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
when (visible.size) { when (visible.size) {
@@ -2595,7 +2603,10 @@ private fun MediaAlbumCell(
val state by vm.state val state by vm.state
val cachedImagePath = state.cachedImagePaths[attachment.id] val cachedImagePath = state.cachedImagePaths[attachment.id]
Box(modifier = modifier.background(Color(0xFFE1E8ED))) { Box(
modifier = modifier
.background(Color(0xFFE1E8ED))
) {
if (attachment.isImageAttachment()) { if (attachment.isImageAttachment()) {
LaunchedEffect(attachment.id, url) { LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment) vm.cacheImage(attachment)
@@ -2663,7 +2674,8 @@ private fun AttachmentView(
attachment: AttachmentDto, attachment: AttachmentDto,
session: QMaxSession, session: QMaxSession,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
val url = rememberAttachmentUrl(session, attachment) val url = rememberAttachmentUrl(session, attachment)
val state by vm.state val state by vm.state
@@ -2683,7 +2695,8 @@ private fun AttachmentView(
session.accessToken, session.accessToken,
cachedImagePath, cachedImagePath,
vm, vm,
imagePreviewSources imagePreviewSources,
mediaShape
) )
attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken) attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken)
attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken) attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken)
@@ -2719,26 +2732,29 @@ private fun ImageAttachment(
token: String, token: String,
cachedImagePath: String?, cachedImagePath: String?,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
LaunchedEffect(attachment.id, url) { LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment) vm.cacheImage(attachment)
} }
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token) val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
val previewSource = cachedImagePath ?: url val previewSource = cachedImagePath ?: url
val aspectRatio = rememberImageAspectRatio(cachedImagePath)
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) } var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) } var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1.25f) .aspectRatio(aspectRatio)
.clip(mediaShape)
.background(Color(0xFFE1E8ED)) .background(Color(0xFFE1E8ED))
.clickable { vm.openImage(previewSource, imagePreviewSources) } .clickable { vm.openImage(previewSource, imagePreviewSources) }
) { ) {
AsyncImage( AsyncImage(
model = model, model = model,
contentDescription = attachment.fileName, contentDescription = attachment.fileName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onSuccess = { onSuccess = {
imageLoaded = true imageLoaded = true
@@ -2755,6 +2771,29 @@ private fun ImageAttachment(
} }
} }
@Composable
private fun rememberImageAspectRatio(cachedImagePath: String?): Float {
var aspectRatio by remember(cachedImagePath) { mutableStateOf(1.25f) }
LaunchedEffect(cachedImagePath) {
val path = cachedImagePath ?: return@LaunchedEffect
val decoded = withContext(Dispatchers.IO) {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(path, options)
if (options.outWidth > 0 && options.outHeight > 0) {
(options.outWidth.toFloat() / options.outHeight.toFloat()).coerceIn(0.58f, 1.9f)
} else {
null
}
}
if (decoded != null) {
aspectRatio = decoded
}
}
return aspectRatio
}
@Composable @Composable
private fun MediaImagePlaceholder(failed: Boolean) { private fun MediaImagePlaceholder(failed: Boolean) {
Box( Box(
@@ -540,9 +540,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val replacement = findReplacementChat(staleChat, orderedFresh) val replacement = findReplacementChat(staleChat, orderedFresh)
if (replacement == null) { if (replacement == null) {
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = null,
chats = orderedFresh, chats = orderedFresh,
messages = emptyList(),
error = null error = null
) )
repository.cacheChats(session, orderedFresh) repository.cacheChats(session, orderedFresh)
@@ -73,6 +73,7 @@ public sealed class MaxBridgeSyncService(
var changed = 0; var changed = 0;
var createdMessages = new List<(Chat Chat, Message Message)>(); var createdMessages = new List<(Chat Chat, Message Message)>();
var updatedMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>();
var previewNotificationMessages = new List<(Chat Chat, Message Message)>();
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal); var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
foreach (var update in updates) foreach (var update in updates)
@@ -190,6 +191,10 @@ public sealed class MaxBridgeSyncService(
createdMessages.Add((chat, previewResult.Message)); createdMessages.Add((chat, previewResult.Message));
} }
} }
if (previewResult.NotificationMessage is not null)
{
previewNotificationMessages.Add((chat, previewResult.NotificationMessage));
}
if (previewResult.Changed) if (previewResult.Changed)
{ {
@@ -350,6 +355,14 @@ public sealed class MaxBridgeSyncService(
} }
} }
foreach (var (chat, message) in previewNotificationMessages)
{
if (sendPushNotifications && message.Direction == MessageDirection.Incoming)
{
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
}
}
foreach (var (chat, message) in updatedMessages) foreach (var (chat, message) in updatedMessages)
{ {
var updated = await db.Messages var updated = await db.Messages
@@ -379,12 +392,12 @@ public sealed class MaxBridgeSyncService(
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview)) if (string.IsNullOrWhiteSpace(preview))
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null);
} }
if (IsPresencePreview(preview)) if (IsPresencePreview(preview))
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null);
} }
var previousPreview = chat.LastMessagePreview; var previousPreview = chat.LastMessagePreview;
@@ -392,7 +405,7 @@ public sealed class MaxBridgeSyncService(
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt); var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null);
} }
var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal); var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
@@ -400,7 +413,7 @@ public sealed class MaxBridgeSyncService(
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1; Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
if (samePreview && samePreviewAt) if (samePreview && samePreviewAt)
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null);
} }
chat.LastMessagePreview = preview; chat.LastMessagePreview = preview;
@@ -425,7 +438,10 @@ public sealed class MaxBridgeSyncService(
var message = shouldNotify && !MessageTextSanitizer.IsGenericMediaLabel(preview) var message = shouldNotify && !MessageTextSanitizer.IsGenericMediaLabel(preview)
? CreatePreviewMessage(chat, preview, previewAt) ? CreatePreviewMessage(chat, preview, previewAt)
: null; : null;
return new ListPreviewApplyResult(true, message); var notificationMessage = shouldNotify && message is null && MessageTextSanitizer.IsGenericMediaLabel(preview)
? CreatePreviewMessage(chat, preview, previewAt)
: null;
return new ListPreviewApplyResult(true, message, notificationMessage);
} }
private static bool IsKnownOutgoingPreviewEcho( private static bool IsKnownOutgoingPreviewEcho(
@@ -604,7 +620,7 @@ public sealed class MaxBridgeSyncService(
text.Contains("file", StringComparison.Ordinal); text.Contains("file", StringComparison.Ordinal);
} }
private sealed record ListPreviewApplyResult(bool Changed, Message? Message); private sealed record ListPreviewApplyResult(bool Changed, Message? Message, Message? NotificationMessage);
private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments); private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
+7
View File
@@ -873,6 +873,7 @@ public sealed class ApiSmokeTests : IDisposable
[Fact] [Fact]
public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate() public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate()
{ {
var pushNotifications = new RecordingPushNotificationService();
var marker = Guid.NewGuid().ToString("N"); var marker = Guid.NewGuid().ToString("N");
var externalId = $"generic-media-sync-chat-{marker}"; var externalId = $"generic-media-sync-chat-{marker}";
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
@@ -899,7 +900,9 @@ public sealed class ApiSmokeTests : IDisposable
{ {
services.RemoveAll<IHostedService>(); services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>(); services.RemoveAll<IMaxBridgeClient>();
services.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge); services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
}); });
}); });
using var client = factory.CreateClient(); using var client = factory.CreateClient();
@@ -926,6 +929,10 @@ public sealed class ApiSmokeTests : IDisposable
x.ChatId == chat.Id && x.ChatId == chat.Id &&
x.ExternalId != null && x.ExternalId != null &&
x.ExternalId.StartsWith("preview:"))); x.ExternalId.StartsWith("preview:")));
var previewPush = Assert.Single(pushNotifications.SentMessages);
Assert.Equal(chat.Id, previewPush.Chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", previewPush.Message.Text);
Assert.Equal(MessageDirection.Incoming, previewPush.Message.Direction);
} }
[Fact] [Fact]