Files
forkmessager/client-mobile/chats/data/sync/MessageSyncWorker.kt
Халимов Рустам 7c66e1c0c0 Кэш чатов
2026-04-20 00:25:40 +03:00

226 lines
7.9 KiB
Kotlin

package chats.data.sync
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import core.database.data.MessageDao
import core.database.data.MessageEntity
import core.database.data.SyncStatus
import core.network.ServerConfig
import core.security.TokenManager
import com.google.gson.Gson
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
/**
* WorkManager Worker для фоновой синхронизации сообщений
*
* Обрабатывает:
* 1. Отправку новых сообщений (SYNCING)
* 2. Обновление отредактированных сообщений (isEditedLocally = true)
* 3. Удаление сообщений (isDeletedLocally = true)
* 4. Повторную отправку при ошибках (FAILED)
*
* Политика повторных попыток:
* - Экспоненциальная задержка (backoff)
* - Максимум 3 попытки
* - Таймаут 10 минут на выполнение
*/
@HiltWorker
class MessageSyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val dao: MessageDao,
private val api: ChatApi,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) : CoroutineWorker(appContext, params) {
private val gson = Gson()
companion object {
const val WORK_NAME = "message_sync_worker"
private const val TAG = "MessageSyncWorker"
/**
* Планирует синхронизацию
* Вызывается при изменении сообщений в базе
*/
fun scheduleSync(context: Context) {
Log.d(TAG, "Scheduling sync")
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(false)
.build()
val workRequest = OneTimeWorkRequestBuilder<MessageSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.addTag(WORK_NAME)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.d(TAG, "Sync scheduled")
}
/**
* Отменяет запланированную синхронизацию
*/
fun cancelSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.d(TAG, "Sync cancelled")
}
}
override suspend fun doWork(): Result {
Log.d(TAG, "Starting message sync. Attempt: ${runAttemptCount + 1}")
if (runAttemptCount >= 3) {
Log.e(TAG, "Max retry attempts reached")
return Result.failure()
}
return try {
val pendingMessages = dao.getPendingSyncMessages()
Log.d(TAG, "Found ${pendingMessages.size} messages to sync")
if (pendingMessages.isEmpty()) {
Log.d(TAG, "No pending messages. Sync complete.")
return Result.success()
}
var successCount = 0
var failureCount = 0
for (message in pendingMessages) {
try {
when {
message.isDeletedLocally -> {
handleDeleteMessage(message)
successCount++
}
message.isEditedLocally -> {
handleEditMessage(message)
successCount++
}
message.syncStatus == SyncStatus.SYNCING -> {
handleSendMessage(message)
successCount++
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to sync message ${message.id}", e)
dao.markAsSyncFailed(message.id)
failureCount++
}
}
Log.d(TAG, "Sync completed. Success: $successCount, Failed: $failureCount")
if (failureCount > 0 && successCount == 0) {
Result.retry()
} else {
Result.success()
}
} catch (e: Exception) {
Log.e(TAG, "Sync failed with exception", e)
Result.retry()
}
}
private suspend fun handleSendMessage(message: MessageEntity) {
Log.d(TAG, "Sending message: ${message.id}")
val attachments = parseAttachments(message.mediaJson)
val request = SendMessageRequest(
content = message.content,
type = message.mediaType.lowercase(),
attachments = attachments,
replyToId = message.replyToId
)
val response = api.sendMessage(message.chatId, request)
val syncedMessage = message.copy(
id = response.id,
sequenceId = response.sequenceId ?: message.sequenceId,
createdAt = response.createdAt ?: message.createdAt,
syncStatus = SyncStatus.SYNCED,
isDeletedLocally = false,
isEditedLocally = false,
editedContent = null,
lastUpdated = System.currentTimeMillis()
)
dao.insertMessage(syncedMessage)
Log.d(TAG, "Message sent successfully: ${response.id}")
}
private suspend fun handleEditMessage(message: MessageEntity) {
Log.d(TAG, "Editing message: ${message.id}")
val newContent = message.editedContent ?: message.content
val request = SendMessageRequest(content = newContent)
val response = api.editMessage(message.id, request)
val syncedMessage = message.copy(
content = response.content,
syncStatus = SyncStatus.SYNCED,
isEditedLocally = false,
editedContent = null,
lastUpdated = System.currentTimeMillis()
)
dao.insertMessage(syncedMessage)
Log.d(TAG, "Message edited successfully: ${message.id}")
}
private suspend fun handleDeleteMessage(message: MessageEntity) {
Log.d(TAG, "Deleting message: ${message.id}")
val response = api.deleteMessage(message.id, forEveryone = false)
if (response.isSuccessful || response.code() == 404) {
dao.deleteMessage(message.id)
Log.d(TAG, "Message deleted successfully: ${message.id}")
} else {
throw Exception("Delete failed with code: ${response.code()}")
}
}
private fun parseAttachments(mediaJson: String): List<chats.data.remote.api.AttachmentRequest>? {
return try {
val mediaList = gson.fromJson(mediaJson, Array::class.java)
?.map { elem ->
val map = elem as Map<*, *>
chats.data.remote.api.AttachmentRequest(
type = map["type"] as? String ?: "file",
url = map["url"] as? String ?: "",
fileName = map["filename"] as? String ?: "file",
fileSize = (map["size"] as? Number)?.toLong() ?: 0L
)
}
mediaList?.takeIf { it.isNotEmpty() }
} catch (e: Exception) {
Log.e(TAG, "Failed to parse attachments", e)
null
}
}
}