Fix media placeholders and image cache

This commit is contained in:
sevenhill
2026-07-06 09:10:31 +03:00
parent 9940f9e43c
commit 61d7aae7eb
10 changed files with 275 additions and 16 deletions
@@ -1310,6 +1310,8 @@ private fun cleanChatPreview(value: String?): String? {
private fun isGenericMediaPreview(value: String?): Boolean {
return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e",
"photo",
"image",
@@ -2600,14 +2602,27 @@ private fun MediaAlbumCell(
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken)
val previewSource = cachedImagePath ?: url
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clickable { vm.openImage(previewSource, imagePreviewSources) }
.clickable { vm.openImage(previewSource, imagePreviewSources) },
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
)
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
} else {
Box(
modifier = Modifier
@@ -2711,16 +2726,58 @@ private fun ImageAttachment(
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
val previewSource = cachedImagePath ?: url
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Crop,
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)
.background(Color(0xFFE1E8ED))
.clickable { vm.openImage(previewSource, imagePreviewSources) }
)
) {
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
)
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
}
}
@Composable
private fun MediaImagePlaceholder(failed: Boolean) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE1E8ED)),
contentAlignment = Alignment.Center
) {
if (failed) {
Icon(
Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = QMaxMuted,
modifier = Modifier.size(28.dp)
)
} else {
CircularProgressIndicator(
color = QMaxBlue,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
}
@Composable
@@ -102,10 +102,10 @@ class QMaxRepository(
return messageCache.messages(session, chatId)
}
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> {
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) }
suspend fun messages(session: QMaxSession, chatId: String, sync: Boolean = false): List<MessageDto> {
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId, sync) }
messageCache.saveMessages(session, chatId, messages)
return messages
return messageCache.messages(session, chatId)
}
suspend fun markChatRead(session: QMaxSession, chatId: String) {
@@ -219,7 +219,7 @@ class QMaxRepository(
suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto {
val meta = queryMeta(uri)
val body = contentUriRequestBody(uri, meta.contentType)
return withFreshSession(session) {
val message = withFreshSession(session) {
api.uploadAttachment(
it.serverUrl,
it.accessToken,
@@ -230,7 +230,12 @@ class QMaxRepository(
caption,
replyToMessageId
)
}.also { messageCache.upsertMessage(session, it) }
}
messageCache.upsertMessage(session, message)
message.attachments.firstOrNull()?.let { attachment ->
cacheUploadedAttachment(session, attachment, uri)
}
return message
}
suspend fun uploadVoice(session: QMaxSession, chatId: String, file: File, replyToMessageId: String?): MessageDto {
@@ -526,6 +531,22 @@ class QMaxRepository(
}
}
private suspend fun cacheUploadedAttachment(session: QMaxSession, attachment: AttachmentDto, uri: Uri) {
runCatching {
attachmentCache.storeFile(session, attachment) { target ->
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Cannot open selected file" }
target.outputStream().use { output ->
input.copyTo(output)
}
}
target.length()
}
}.onFailure { error ->
Log.w(RepositoryLogTag, "Cannot prefill attachment cache", error)
}
}
private data class FileMeta(val fileName: String, val contentType: String)
private suspend fun <T> withFreshSession(
@@ -573,6 +594,7 @@ class QMaxRepository(
}
}
private const val RepositoryLogTag = "QMaxRepository"
private const val PushLogTag = "QMaxPush"
private data class SharedAttachment(
@@ -37,6 +37,36 @@ class AttachmentDiskCache(context: Context) {
}
}
suspend fun storeFile(
session: QMaxSession,
attachment: AttachmentDto,
writer: suspend (File) -> Long
): File {
return withContext(Dispatchers.IO) {
val target = targetFile(session, attachment)
target.parentFile?.mkdirs()
val part = File(target.parentFile, target.name + ".part")
if (part.exists()) part.delete()
writer(part)
if (!isValid(part, attachment)) {
val expected = attachment.fileSizeBytes.takeIf { it > 0 }?.toString() ?: "non-empty"
val actual = if (part.exists()) part.length().toString() else "missing"
part.delete()
error("Attachment cache mismatch: expected $expected, got $actual")
}
if (target.exists()) target.delete()
if (!part.renameTo(target)) {
part.delete()
error("Cannot move uploaded file into attachment cache")
}
target
}
}
suspend fun clearAll() {
withContext(Dispatchers.IO) {
root.deleteRecursively()
@@ -145,6 +145,8 @@ class LocalMessageCache(context: Context) {
private fun isGenericMediaLabel(value: String?): Boolean {
return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e",
"photo",
"image",
@@ -79,8 +79,8 @@ class QMaxApi {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
}
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> {
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=false", token)
suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=$sync", token)
}
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) {
@@ -81,6 +81,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
private val imageCacheInFlight = mutableSetOf<String>()
private val avatarCacheInFlight = mutableSetOf<String>()
private val messageHydrationInFlight = mutableSetOf<String>()
private val lastMessageHydrationAt = mutableMapOf<String, Long>()
init {
viewModelScope.launch {
@@ -88,6 +90,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val previousSession = state.value.session
val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
previousSession?.userName != session?.userName
if (sessionChanged) {
messageHydrationInFlight.clear()
lastMessageHydrationAt.clear()
}
state.value = state.value.copy(
session = session,
serverUrl = session?.serverUrl ?: state.value.serverUrl,
@@ -330,7 +336,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
realtime.joinChat(chat.id)
}
loadMessages(showLoading = false)
loadMessages(showLoading = false, forceHydrate = true)
presencePollJob?.cancel()
presencePollJob = viewModelScope.launch {
while (state.value.selectedChat?.id == chat.id) {
@@ -456,7 +462,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false)
}
fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) {
fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) {
return@launchLoading
}
@@ -485,6 +491,39 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
messages = fresh
)
persistCurrentChats()
hydrateMessages(chat.id, force = forceHydrate)
}
}
private fun hydrateMessages(chatId: String, force: Boolean = false) {
val session = state.value.session ?: return
val now = System.currentTimeMillis()
val lastHydratedAt = lastMessageHydrationAt[chatId] ?: 0L
if (!force && now - lastHydratedAt < MessageHydrationIntervalMs) {
return
}
if (!messageHydrationInFlight.add(chatId)) {
return
}
lastMessageHydrationAt[chatId] = now
viewModelScope.launch {
try {
val hydrated = withTimeout(MessageSyncTimeoutMs) {
repository.messages(session, chatId, sync = true)
}
if (state.value.selectedChat?.id == chatId) {
state.value = state.value.copy(messages = hydrated)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (BuildConfig.DEBUG) {
Log.d(LogTag, "Message hydration failed", error)
}
} finally {
messageHydrationInFlight.remove(chatId)
}
}
}
@@ -1236,6 +1275,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val LogTag = "QMAX"
const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L
const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L
@@ -106,6 +106,7 @@ public sealed class ChatsController(
}
var messages = (await query.ToArrayAsync(cancellationToken))
.Where(message => !IsGenericPreviewPlaceholder(message))
.OrderByDescending(x => x.SentAt)
.Take(Math.Clamp(take, 1, 200))
.ToArray();
@@ -121,6 +122,11 @@ public sealed class ChatsController(
return;
}
if (MessageTextSanitizer.IsGenericMediaLabel(preview))
{
return;
}
var previewAt = chat.LastMessageAt.Value;
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{
@@ -202,6 +208,14 @@ public sealed class ChatsController(
return hash.ToString("x8");
}
private static bool IsGenericPreviewPlaceholder(Message message)
{
return message.Direction == MessageDirection.Incoming &&
message.Attachments.Count == 0 &&
message.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true &&
MessageTextSanitizer.IsGenericMediaLabel(message.Text);
}
[HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
{
@@ -422,7 +422,7 @@ public sealed class MaxBridgeSyncService(
chat.UnreadCount++;
}
var message = shouldNotify
var message = shouldNotify && !MessageTextSanitizer.IsGenericMediaLabel(preview)
? CreatePreviewMessage(chat, preview, previewAt)
: null;
return new ListPreviewApplyResult(true, message);
@@ -35,6 +35,8 @@ public static class MessageTextSanitizer
{
return value?.Trim().ToLowerInvariant() switch
{
"\u043c\u0435\u0434\u0438\u0430" => true,
"media" => true,
"\u0444\u043e\u0442\u043e" => true,
"photo" => true,
"image" => true,
+92
View File
@@ -773,6 +773,40 @@ public sealed class ApiSmokeTests : IDisposable
Assert.StartsWith("preview:", backfilled.ExternalId);
}
[Fact]
public async Task GetMessagesDoesNotBackfillGenericMediaPreviewWhenAttachmentRowIsMissing()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var marker = Guid.NewGuid().ToString("N");
var chat = await CreateDirectChatAsync(client, $"generic-media-preview-chat-{marker}", "Generic Media Preview");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.LastMessagePreview = "\u0424\u043e\u0442\u043e";
stored.LastMessageAt = DateTimeOffset.UtcNow;
stored.UnreadCount = 1;
await db.SaveChangesAsync();
}
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.NotNull(messages);
Assert.DoesNotContain(messages!, x => x.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
Assert.False(await verifyDb.Messages.AnyAsync(x => x.ChatId == chat.Id));
}
[Fact]
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate()
{
@@ -836,6 +870,64 @@ public sealed class ApiSmokeTests : IDisposable
}
}
[Fact]
public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate()
{
var marker = Guid.NewGuid().ToString("N");
var externalId = $"generic-media-sync-chat-{marker}";
var now = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
externalId,
"Generic Media Sync",
null,
now,
[],
"\u0424\u043e\u0442\u043e",
false,
now,
now.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
$"https://max.test/chat/{externalId}")
]
};
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, externalId, "Generic Media Sync");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.LastMessagePreview = "previous preview";
stored.LastMessageAt = now.AddMinutes(-5);
await db.SaveChangesAsync();
}
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await verifyDb.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", storedChat.LastMessagePreview);
Assert.False(await verifyDb.Messages.AnyAsync(x =>
x.ChatId == chat.Id &&
x.ExternalId != null &&
x.ExternalId.StartsWith("preview:")));
}
[Fact]
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
{