Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30361370da | |||
| 61d7aae7eb |
@@ -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
|
||||||
@@ -1310,6 +1313,8 @@ private fun cleanChatPreview(value: String?): String? {
|
|||||||
|
|
||||||
private fun isGenericMediaPreview(value: String?): Boolean {
|
private fun isGenericMediaPreview(value: String?): Boolean {
|
||||||
return when (value?.trim()?.lowercase()) {
|
return when (value?.trim()?.lowercase()) {
|
||||||
|
"\u043c\u0435\u0434\u0438\u0430",
|
||||||
|
"media",
|
||||||
"\u0444\u043e\u0442\u043e",
|
"\u0444\u043e\u0442\u043e",
|
||||||
"photo",
|
"photo",
|
||||||
"image",
|
"image",
|
||||||
@@ -2224,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 ->
|
||||||
@@ -2232,7 +2238,8 @@ private fun MessageRow(
|
|||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
session = session,
|
session = session,
|
||||||
vm = vm,
|
vm = vm,
|
||||||
imagePreviewSources = imagePreviewSources
|
imagePreviewSources = imagePreviewSources,
|
||||||
|
mediaShape = shape
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2512,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) {
|
||||||
@@ -2593,21 +2603,37 @@ 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)
|
||||||
}
|
}
|
||||||
val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken)
|
val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken)
|
||||||
val previewSource = cachedImagePath ?: url
|
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(
|
AsyncImage(
|
||||||
model = model,
|
model = model,
|
||||||
contentDescription = attachment.fileName,
|
contentDescription = attachment.fileName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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 {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -2648,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
|
||||||
@@ -2668,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)
|
||||||
@@ -2704,23 +2732,91 @@ 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
|
||||||
AsyncImage(
|
val aspectRatio = rememberImageAspectRatio(cachedImagePath)
|
||||||
model = model,
|
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
|
||||||
contentDescription = attachment.fileName,
|
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
|
||||||
contentScale = ContentScale.Crop,
|
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(
|
||||||
|
model = model,
|
||||||
|
contentDescription = attachment.fileName,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onSuccess = {
|
||||||
|
imageLoaded = true
|
||||||
|
imageFailed = false
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
imageLoaded = false
|
||||||
|
imageFailed = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!imageLoaded) {
|
||||||
|
MediaImagePlaceholder(failed = imageFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
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
|
@Composable
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ class QMaxRepository(
|
|||||||
return messageCache.messages(session, chatId)
|
return messageCache.messages(session, chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> {
|
suspend fun messages(session: QMaxSession, chatId: String, sync: Boolean = false): List<MessageDto> {
|
||||||
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) }
|
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId, sync) }
|
||||||
messageCache.saveMessages(session, chatId, messages)
|
messageCache.saveMessages(session, chatId, messages)
|
||||||
return messages
|
return messageCache.messages(session, chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun markChatRead(session: QMaxSession, chatId: String) {
|
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 {
|
suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto {
|
||||||
val meta = queryMeta(uri)
|
val meta = queryMeta(uri)
|
||||||
val body = contentUriRequestBody(uri, meta.contentType)
|
val body = contentUriRequestBody(uri, meta.contentType)
|
||||||
return withFreshSession(session) {
|
val message = withFreshSession(session) {
|
||||||
api.uploadAttachment(
|
api.uploadAttachment(
|
||||||
it.serverUrl,
|
it.serverUrl,
|
||||||
it.accessToken,
|
it.accessToken,
|
||||||
@@ -230,7 +230,12 @@ class QMaxRepository(
|
|||||||
caption,
|
caption,
|
||||||
replyToMessageId
|
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 {
|
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 data class FileMeta(val fileName: String, val contentType: String)
|
||||||
|
|
||||||
private suspend fun <T> withFreshSession(
|
private suspend fun <T> withFreshSession(
|
||||||
@@ -573,6 +594,7 @@ class QMaxRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val RepositoryLogTag = "QMaxRepository"
|
||||||
private const val PushLogTag = "QMaxPush"
|
private const val PushLogTag = "QMaxPush"
|
||||||
|
|
||||||
private data class SharedAttachment(
|
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() {
|
suspend fun clearAll() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
root.deleteRecursively()
|
root.deleteRecursively()
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ class LocalMessageCache(context: Context) {
|
|||||||
|
|
||||||
private fun isGenericMediaLabel(value: String?): Boolean {
|
private fun isGenericMediaLabel(value: String?): Boolean {
|
||||||
return when (value?.trim()?.lowercase()) {
|
return when (value?.trim()?.lowercase()) {
|
||||||
|
"\u043c\u0435\u0434\u0438\u0430",
|
||||||
|
"media",
|
||||||
"\u0444\u043e\u0442\u043e",
|
"\u0444\u043e\u0442\u043e",
|
||||||
"photo",
|
"photo",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ class QMaxApi {
|
|||||||
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
|
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> {
|
suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
|
||||||
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=false", token)
|
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=$sync", token)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) {
|
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 locallyReadChatFingerprints = mutableMapOf<String, String>()
|
||||||
private val imageCacheInFlight = mutableSetOf<String>()
|
private val imageCacheInFlight = mutableSetOf<String>()
|
||||||
private val avatarCacheInFlight = mutableSetOf<String>()
|
private val avatarCacheInFlight = mutableSetOf<String>()
|
||||||
|
private val messageHydrationInFlight = mutableSetOf<String>()
|
||||||
|
private val lastMessageHydrationAt = mutableMapOf<String, Long>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -88,6 +90,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
val previousSession = state.value.session
|
val previousSession = state.value.session
|
||||||
val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
|
val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
|
||||||
previousSession?.userName != session?.userName
|
previousSession?.userName != session?.userName
|
||||||
|
if (sessionChanged) {
|
||||||
|
messageHydrationInFlight.clear()
|
||||||
|
lastMessageHydrationAt.clear()
|
||||||
|
}
|
||||||
state.value = state.value.copy(
|
state.value = state.value.copy(
|
||||||
session = session,
|
session = session,
|
||||||
serverUrl = session?.serverUrl ?: state.value.serverUrl,
|
serverUrl = session?.serverUrl ?: state.value.serverUrl,
|
||||||
@@ -330,7 +336,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
realtime.joinChat(chat.id)
|
realtime.joinChat(chat.id)
|
||||||
}
|
}
|
||||||
loadMessages(showLoading = false)
|
loadMessages(showLoading = false, forceHydrate = true)
|
||||||
presencePollJob?.cancel()
|
presencePollJob?.cancel()
|
||||||
presencePollJob = viewModelScope.launch {
|
presencePollJob = viewModelScope.launch {
|
||||||
while (state.value.selectedChat?.id == chat.id) {
|
while (state.value.selectedChat?.id == chat.id) {
|
||||||
@@ -456,7 +462,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
refreshChats(showLoading = false)
|
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) {
|
if (!showLoading && state.value.sendingMessage) {
|
||||||
return@launchLoading
|
return@launchLoading
|
||||||
}
|
}
|
||||||
@@ -485,6 +491,39 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
messages = fresh
|
messages = fresh
|
||||||
)
|
)
|
||||||
persistCurrentChats()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,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)
|
||||||
@@ -1236,6 +1273,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
const val LogTag = "QMAX"
|
const val LogTag = "QMAX"
|
||||||
const val ChatListTimeoutMs = 45_000L
|
const val ChatListTimeoutMs = 45_000L
|
||||||
const val MessageSyncTimeoutMs = 120_000L
|
const val MessageSyncTimeoutMs = 120_000L
|
||||||
|
const val MessageHydrationIntervalMs = 120_000L
|
||||||
const val AutoLoginDelayMs = 1_000L
|
const val AutoLoginDelayMs = 1_000L
|
||||||
const val PushRegistrationAttempts = 6
|
const val PushRegistrationAttempts = 6
|
||||||
const val InitialPushRegistrationRetryMs = 15_000L
|
const val InitialPushRegistrationRetryMs = 15_000L
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ public sealed class ChatsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var messages = (await query.ToArrayAsync(cancellationToken))
|
var messages = (await query.ToArrayAsync(cancellationToken))
|
||||||
|
.Where(message => !IsGenericPreviewPlaceholder(message))
|
||||||
.OrderByDescending(x => x.SentAt)
|
.OrderByDescending(x => x.SentAt)
|
||||||
.Take(Math.Clamp(take, 1, 200))
|
.Take(Math.Clamp(take, 1, 200))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -121,6 +122,11 @@ public sealed class ChatsController(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (MessageTextSanitizer.IsGenericMediaLabel(preview))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var previewAt = chat.LastMessageAt.Value;
|
var previewAt = chat.LastMessageAt.Value;
|
||||||
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
|
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
|
||||||
{
|
{
|
||||||
@@ -202,6 +208,14 @@ public sealed class ChatsController(
|
|||||||
return hash.ToString("x8");
|
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")]
|
[HttpPost("{chatId:guid}/read")]
|
||||||
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
|
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -422,10 +435,13 @@ public sealed class MaxBridgeSyncService(
|
|||||||
chat.UnreadCount++;
|
chat.UnreadCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
var message = shouldNotify
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public static class MessageTextSanitizer
|
|||||||
{
|
{
|
||||||
return value?.Trim().ToLowerInvariant() switch
|
return value?.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
|
"\u043c\u0435\u0434\u0438\u0430" => true,
|
||||||
|
"media" => true,
|
||||||
"\u0444\u043e\u0442\u043e" => true,
|
"\u0444\u043e\u0442\u043e" => true,
|
||||||
"photo" => true,
|
"photo" => true,
|
||||||
"image" => true,
|
"image" => true,
|
||||||
|
|||||||
@@ -773,6 +773,40 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.StartsWith("preview:", backfilled.ExternalId);
|
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]
|
[Fact]
|
||||||
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate()
|
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate()
|
||||||
{
|
{
|
||||||
@@ -836,6 +870,71 @@ 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;
|
||||||
|
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.RemoveAll<IPushNotificationService>();
|
||||||
|
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||||
|
services.AddSingleton<IPushNotificationService>(pushNotifications);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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:")));
|
||||||
|
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]
|
||||||
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
|
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user