diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index fa91372..eb612a5 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -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, session: QMaxSession, vm: QMaxViewModel, - imagePreviewSources: List + imagePreviewSources: List, + 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 + imagePreviewSources: List, + 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 + imagePreviewSources: List, + 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( diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index 0a8fb6c..b4208f7 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -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) diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index f9ced5d..28467fc 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -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(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 AddedAttachments); diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index f732141..d2d6446 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -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(); services.RemoveAll(); + services.RemoveAll(); services.AddSingleton(bridge); + services.AddSingleton(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]