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.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
@@ -157,8 +158,10 @@ import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
@@ -2226,7 +2229,8 @@ private fun MessageRow(
attachments = visualAttachments,
session = session,
vm = vm,
imagePreviewSources = imagePreviewSources
imagePreviewSources = imagePreviewSources,
mediaShape = shape
)
} else {
sortedAttachments.forEach { attachment ->
@@ -2234,7 +2238,8 @@ private fun MessageRow(
attachment = attachment,
session = session,
vm = vm,
imagePreviewSources = imagePreviewSources
imagePreviewSources = imagePreviewSources,
mediaShape = shape
)
}
}
@@ -2514,13 +2519,16 @@ private fun MediaAlbumGrid(
attachments: List<AttachmentDto>,
session: QMaxSession,
vm: QMaxViewModel,
imagePreviewSources: List<String>
imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) {
val visible = attachments.take(4)
val extraCount = (attachments.size - visible.size).coerceAtLeast(0)
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clip(mediaShape),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
when (visible.size) {
@@ -2595,7 +2603,10 @@ private fun MediaAlbumCell(
val state by vm.state
val cachedImagePath = state.cachedImagePaths[attachment.id]
Box(modifier = modifier.background(Color(0xFFE1E8ED))) {
Box(
modifier = modifier
.background(Color(0xFFE1E8ED))
) {
if (attachment.isImageAttachment()) {
LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment)
@@ -2663,7 +2674,8 @@ private fun AttachmentView(
attachment: AttachmentDto,
session: QMaxSession,
vm: QMaxViewModel,
imagePreviewSources: List<String>
imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) {
val url = rememberAttachmentUrl(session, attachment)
val state by vm.state
@@ -2683,7 +2695,8 @@ private fun AttachmentView(
session.accessToken,
cachedImagePath,
vm,
imagePreviewSources
imagePreviewSources,
mediaShape
)
attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken)
attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken)
@@ -2719,26 +2732,29 @@ private fun ImageAttachment(
token: String,
cachedImagePath: String?,
vm: QMaxViewModel,
imagePreviewSources: List<String>
imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) {
LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment)
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
val previewSource = cachedImagePath ?: url
val aspectRatio = rememberImageAspectRatio(cachedImagePath)
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.25f)
.aspectRatio(aspectRatio)
.clip(mediaShape)
.background(Color(0xFFE1E8ED))
.clickable { vm.openImage(previewSource, imagePreviewSources) }
) {
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Crop,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
onSuccess = {
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
private fun MediaImagePlaceholder(failed: Boolean) {
Box(
@@ -540,9 +540,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val replacement = findReplacementChat(staleChat, orderedFresh)
if (replacement == null) {
state.value = state.value.copy(
selectedChat = null,
chats = orderedFresh,
messages = emptyList(),
error = null
)
repository.cacheChats(session, orderedFresh)
@@ -73,6 +73,7 @@ public sealed class MaxBridgeSyncService(
var changed = 0;
var createdMessages = 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);
foreach (var update in updates)
@@ -190,6 +191,10 @@ public sealed class MaxBridgeSyncService(
createdMessages.Add((chat, previewResult.Message));
}
}
if (previewResult.NotificationMessage is not null)
{
previewNotificationMessages.Add((chat, previewResult.NotificationMessage));
}
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)
{
var updated = await db.Messages
@@ -379,12 +392,12 @@ public sealed class MaxBridgeSyncService(
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview))
{
return new ListPreviewApplyResult(false, null);
return new ListPreviewApplyResult(false, null, null);
}
if (IsPresencePreview(preview))
{
return new ListPreviewApplyResult(false, null);
return new ListPreviewApplyResult(false, null, null);
}
var previousPreview = chat.LastMessagePreview;
@@ -392,7 +405,7 @@ public sealed class MaxBridgeSyncService(
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
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);
@@ -400,7 +413,7 @@ public sealed class MaxBridgeSyncService(
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
if (samePreview && samePreviewAt)
{
return new ListPreviewApplyResult(false, null);
return new ListPreviewApplyResult(false, null, null);
}
chat.LastMessagePreview = preview;
@@ -425,7 +438,10 @@ public sealed class MaxBridgeSyncService(
var message = shouldNotify && !MessageTextSanitizer.IsGenericMediaLabel(preview)
? CreatePreviewMessage(chat, preview, previewAt)
: 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(
@@ -604,7 +620,7 @@ public sealed class MaxBridgeSyncService(
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);
+7
View File
@@ -873,6 +873,7 @@ public sealed class ApiSmokeTests : IDisposable
[Fact]
public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate()
{
var pushNotifications = new RecordingPushNotificationService();
var marker = Guid.NewGuid().ToString("N");
var externalId = $"generic-media-sync-chat-{marker}";
var now = DateTimeOffset.UtcNow;
@@ -899,7 +900,9 @@ public sealed class ApiSmokeTests : IDisposable
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
});
});
using var client = factory.CreateClient();
@@ -926,6 +929,10 @@ public sealed class ApiSmokeTests : IDisposable
x.ChatId == chat.Id &&
x.ExternalId != null &&
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]