Второй этап выхода из офлайн режима

This commit is contained in:
Халимов Рустам
2026-04-20 10:46:09 +03:00
parent b2c29958b5
commit c839a9bc03
19 changed files with 899 additions and 84 deletions
Binary file not shown.
Binary file not shown.
+186
View File
@@ -0,0 +1,186 @@
# Умная синхронизация сообщений при восстановлении сети
## Проблема
При восстановлении соединения после офлайна, приложение запрашивало **все сообщения заново** (последние 50 для каждого чата), вместо того чтобы получить только **новые сообщения**, которые пришли пока клиент был офлайн.
## Решение
Добавлен новый параметр API `afterSequenceId` который позволяет запрашивать только сообщения с sequenceId > указанного.
## Изменения
### Бэкенд
#### 1. IMessageRepository.cs
Добавлен метод для получения сообщений ПОСЛЕ указанного sequenceId:
```csharp
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
```
#### 2. MessageRepository.cs
Реализация метода:
```csharp
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
var filter = builder.And(
builder.Eq(m => m.ChatId, chatId),
builder.Gt(m => m.SequenceId, sequenceId)
);
return await _messages.Find(filter)
.SortBy(m => m.SequenceId)
.Limit(limit)
.ToListAsync(cancellationToken);
}
```
#### 3. GetMessagesQuery.cs
Добавлен параметр `AfterSequenceId`:
```csharp
public record GetMessagesQuery(
Guid UserId,
Guid ChatId,
string? Cursor,
long? Pivot = null,
long? AfterSequenceId = null, // НОВЫЙ ПАРАМЕТР
int? Limit = null
) : IQuery<List<MessageDetailDto>>;
```
Обработчик использует новый метод:
```csharp
if (request.AfterSequenceId.HasValue)
{
messages = await _messageRepository.GetChatMessagesAfterAsync(
request.ChatId,
request.AfterSequenceId.Value,
queryLimit,
cancellationToken
);
}
```
#### 4. MessagesEndpoints.cs
Добавлен query параметр в endpoint:
```csharp
group.MapGet("chat/{chatId:guid}", async (
[FromRoute] Guid chatId,
[FromQuery] string? cursor,
[FromQuery] long? afterSequenceId, // НОВЫЙ ПАРАМЕТР
[FromQuery] long? pivot,
[FromQuery] int? limit,
ISender sender,
IUserContext userContext,
CancellationToken ct) => { ... }
);
```
### Мобильное приложение
#### 1. ChatApi.kt
Добавлен параметр в API:
```kotlin
@GET("messages/chat/{chatId}")
suspend fun getMessages(
@Path("chatId") chatId: String,
@Query("cursor") cursor: String? = null,
@Query("pivot") pivot: Long? = null,
@Query("afterSequenceId") afterSequenceId: Long? = null, // НОВЫЙ ПАРАМЕТР
@Query("limit") limit: Int? = 50
): List<MessageDto>
```
#### 2. ChatRepository.kt
Добавлен метод:
```kotlin
suspend fun getMessages(
chatId: String,
cursor: String? = null,
pivot: Long? = null,
afterSequenceId: Long? = null, // НОВЫЙ ПАРАМЕТР
limit: Int? = null
): List<Message>
```
Добавлен метод для получения последнего sequenceId:
```kotlin
suspend fun getLastKnownSequenceId(chatId: String): Int?
```
#### 3. ChatRepositoryImpl.kt
Реализация:
```kotlin
override suspend fun getLastKnownSequenceId(chatId: String): Int? {
return try {
messageDao.getMaxSequenceId(chatId)
} catch (e: Exception) {
Log.e(TAG, "Failed to get last sequenceId", e)
null
}
}
```
#### 4. SignalRNotificationObserver.kt
Умная синхронизация:
```kotlin
private fun syncMissedMessages() {
scope.launch {
val chats = chatRepository.getChats()
chats.forEach { chat ->
val lastSequenceId = chatRepository.getLastKnownSequenceId(chat.id)
if (lastSequenceId != null) {
// Запрашиваем только сообщения ПОСЛЕ последнего известного
chatRepository.getMessages(
chatId = chat.id,
afterSequenceId = lastSequenceId.toLong(),
limit = 100
)
} else {
// Нет локальных сообщений - загружаем последние 50
chatRepository.getMessages(chatId = chat.id, limit = 50)
}
}
}
}
```
## Преимущества
### До изменений
- Запрашивались **все последние 50 сообщений** для каждого чата
- При 20 чатах: 20 × 50 = **1000 сообщений** из сети
- Дублирование данных, медленная синхронизация
### После изменений
- Запрашиваются **только новые сообщения** с последнего sequenceId
- При 20 чатах и 5 новых сообщениях в каждом: 20 × 5 = **100 сообщений**
- **В 10 раз меньше трафика**
- **Быстрая синхронизация**
## Пример использования API
```http
GET /api/messages/chat/{chatId}?afterSequenceId=12345&limit=100
```
Ответит только с сообщениями где `sequenceId > 12345`.
## Тестирование
1. Откройте приложение, загрузите чаты
2. Отключите сеть
3. Отправьте несколько сообщений с другого устройства
4. Включите сеть
5. Проверьте логи: должно быть `fetching newer...` и загрузка только новых сообщений
## Логи
```
SignalRNtfObserver: Starting missed messages sync...
SignalRNtfObserver: Syncing 5 chats for missed messages
SignalRNtfObserver: Chat abc-123: last known seqId=100, fetching newer...
SignalRNtfObserver: Chat abc-123: fetched 3 new messages
SignalRNtfObserver: Synced chat abc-123
```
@@ -3,6 +3,7 @@
package="ru.knot.messager"> package="ru.knot.messager">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@@ -22,7 +22,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
android.util.Log.d("MainActivity", "onCreate called")
signalrNotificationObserver.start() signalrNotificationObserver.start()
android.util.Log.d("MainActivity", "signalrNotificationObserver.start() called")
intent.getStringExtra("chatId")?.let { chatId -> intent.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId) navigationManager.navigateToChat(chatId)
@@ -29,6 +29,7 @@ interface ChatApi {
@Path("chatId") chatId: String, @Path("chatId") chatId: String,
@Query("cursor") cursor: String? = null, @Query("cursor") cursor: String? = null,
@Query("pivot") pivot: Long? = null, @Query("pivot") pivot: Long? = null,
@Query("afterSequenceId") afterSequenceId: Long? = null,
@Query("limit") limit: Int? = 50 @Query("limit") limit: Int? = 50
): List<MessageDto> ): List<MessageDto>
@@ -45,7 +45,8 @@ enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton @Singleton
class ChatHubClient @Inject constructor() { class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024) // extraBufferCapacity=1024 позволяет буферизовать события пока нет подписчиков
private val _events = MutableSharedFlow<ChatEvent>(replay = 0, extraBufferCapacity = 1024)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow() val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED) private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
@@ -56,32 +57,59 @@ class ChatHubClient @Inject constructor() {
private var lastToken: String? = null private var lastToken: String? = null
fun connect(baseUrl: String, accessToken: String) { fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return // Проверяем текущее состояние
val currentState = hubConnection?.connectionState
if (currentState == HubConnectionState.CONNECTED) {
Log.d("ChatHubClient", "Already connected, skipping")
return
}
// Если соединение в процессе - останавливаем его
if (currentState == HubConnectionState.CONNECTING) {
Log.d("ChatHubClient", "Connection in progress ($currentState), stopping first...")
hubConnection?.stop()
}
// Сохраняем параметры для переподключения
lastBaseUrl = baseUrl lastBaseUrl = baseUrl
lastToken = accessToken lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING _status.value = ConnectionStatus.CONNECTING
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat") Log.d("ChatHubClient", "Connecting to ${baseUrl}/hubs/chat with token: ${accessToken.take(10)}...")
// Создаем новое соединение
val newHubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
.withAccessTokenProvider(Single.just(accessToken)) .withAccessTokenProvider(Single.just(accessToken))
.build() .build()
hubConnection = newHubConnection
setupHandlers() setupHandlers()
hubConnection?.onClosed { exception -> hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception) Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
_status.value = ConnectionStatus.DISCONNECTED _status.value = ConnectionStatus.DISCONNECTED
scope.launch { scope.launch {
// Проверяем, есть ли еще актуальные параметры для переподключения
val reconnectBaseUrl = lastBaseUrl
val reconnectToken = lastToken
if (reconnectBaseUrl != null && reconnectToken != null) {
Log.d("ChatHubClient", "Attempting reconnection with saved parameters...")
delay(5000) delay(5000)
connect(baseUrl, accessToken) connect(reconnectBaseUrl, reconnectToken)
} else {
Log.w("ChatHubClient", "Cannot reconnect: missing baseUrl or token")
}
} }
} }
scope.launch { scope.launch {
try { try {
Log.d("ChatHubClient", "Starting SignalR connection...")
hubConnection?.start()?.blockingAwait() hubConnection?.start()?.blockingAwait()
_status.value = ConnectionStatus.CONNECTED _status.value = ConnectionStatus.CONNECTED
Log.d("ChatHubClient", "SignalR Connected") Log.d("ChatHubClient", "SignalR Connected successfully!")
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e) Log.e("ChatHubClient", "SignalR Connection Error", e)
_status.value = ConnectionStatus.DISCONNECTED _status.value = ConnectionStatus.DISCONNECTED
@@ -91,19 +119,25 @@ class ChatHubClient @Inject constructor() {
private fun setupHandlers() { private fun setupHandlers() {
hubConnection?.let { conn -> hubConnection?.let { conn ->
Log.d("ChatHubClient", "Setting up SignalR handlers")
conn.on("new_message", { message: MessageDto -> conn.on("new_message", { message: MessageDto ->
Log.d("ChatHubClient", ">>> new_message event received: ${message.id} in chat ${message.chatId}")
_events.tryEmit(ChatEvent.NewMessage(message)) _events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java) }, MessageDto::class.java)
conn.on("message_edited", { messageId: String, chatId: String, content: String -> conn.on("message_edited", { messageId: String, chatId: String, content: String ->
Log.d("ChatHubClient", ">>> message_edited event: $messageId")
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content)) _events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
}, String::class.java, String::class.java, String::class.java) }, String::class.java, String::class.java, String::class.java)
conn.on("message_deleted", { messageId: String, chatId: String -> conn.on("message_deleted", { messageId: String, chatId: String ->
Log.d("ChatHubClient", ">>> message_deleted event: $messageId")
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId)) _events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
}, String::class.java, String::class.java) }, String::class.java, String::class.java)
conn.on("messages_read", { data: MessagesReadEvent -> conn.on("messages_read", { data: MessagesReadEvent ->
Log.d("ChatHubClient", ">>> messages_read event: ${data.effectiveChatId}")
_events.tryEmit(ChatEvent.MessagesRead( _events.tryEmit(ChatEvent.MessagesRead(
data.effectiveChatId, data.effectiveChatId,
data.effectiveUserId, data.effectiveUserId,
@@ -124,10 +158,12 @@ class ChatHubClient @Inject constructor() {
}, String::class.java) }, String::class.java)
conn.on("new_chat", { chat: ChatDto -> conn.on("new_chat", { chat: ChatDto ->
Log.d("ChatHubClient", ">>> new_chat event: ${chat.id}")
_events.tryEmit(ChatEvent.NewChat(chat)) _events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java) }, ChatDto::class.java)
conn.on("reaction_added", { data: ReactionEvent -> conn.on("reaction_added", { data: ReactionEvent ->
Log.d("ChatHubClient", ">>> reaction_added event: ${data.emoji} on ${data.messageId}")
_events.tryEmit(ChatEvent.ReactionUpdated( _events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "", data.messageId ?: "",
data.chatId ?: "", data.chatId ?: "",
@@ -138,6 +174,7 @@ class ChatHubClient @Inject constructor() {
}, ReactionEvent::class.java) }, ReactionEvent::class.java)
conn.on("reaction_removed", { data: ReactionEvent -> conn.on("reaction_removed", { data: ReactionEvent ->
Log.d("ChatHubClient", ">>> reaction_removed event: ${data.emoji} on ${data.messageId}")
_events.tryEmit(ChatEvent.ReactionUpdated( _events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "", data.messageId ?: "",
data.chatId ?: "", data.chatId ?: "",
@@ -185,6 +222,26 @@ class ChatHubClient @Inject constructor() {
_status.value = ConnectionStatus.DISCONNECTED _status.value = ConnectionStatus.DISCONNECTED
} }
/**
* Принудительное переподключение - останавливает текущее соединение и создает новое
*/
fun reconnect() {
Log.d("ChatHubClient", "Forced reconnect requested")
val baseUrl = lastBaseUrl
val token = lastToken
if (baseUrl != null && token != null) {
disconnect()
// Небольшая задержка перед переподключением
scope.launch {
delay(1000)
connect(baseUrl, token)
}
} else {
Log.w("ChatHubClient", "Cannot reconnect: missing saved credentials")
}
}
fun addReaction(messageId: String, chatId: String, emoji: String) { fun addReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("add_reaction", mapOf( hubConnection?.invoke("add_reaction", mapOf(
@@ -250,12 +307,13 @@ class ChatHubClient @Inject constructor() {
} }
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
Log.d("ChatHubClient", "Joining chat room: $chatId")
hubConnection?.invoke("join_chat", chatId) hubConnection?.invoke("join_chat", chatId)
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) } ?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
?.subscribe() ?.subscribe()
Log.d("ChatHubClient", "Joined chat room: $chatId") Log.d("ChatHubClient", "Joined chat room: $chatId")
} else { } else {
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected") Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected (state=${hubConnection?.connectionState})")
} }
} }
} }
@@ -1,9 +1,12 @@
package chats.data.remote.signalr package chats.data.remote.signalr
import android.content.Context import android.content.Context
import core.network.NetworkManager
import core.network.ServerConfig
import core.notifications.data.ActiveChatTracker import core.notifications.data.ActiveChatTracker
import core.notifications.data.NotificationHelper import core.notifications.data.NotificationHelper
import core.security.TokenManager import core.security.TokenManager
import chats.data.sync.MessageSyncWorker
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -12,8 +15,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.collect
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import chats.data.remote.signalr.ConnectionStatus
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
@Singleton @Singleton
class SignalRNotificationObserver @Inject constructor( class SignalRNotificationObserver @Inject constructor(
@@ -21,41 +31,92 @@ class SignalRNotificationObserver @Inject constructor(
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val chatRepository: chats.domain.repository.ChatRepository, private val chatRepository: chats.domain.repository.ChatRepository,
private val serverConfig: ServerConfig,
private val networkManager: NetworkManager,
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isStarted = false private var isStarted = false
private val processedMessageIds = mutableSetOf<String>() private val processedMessageIds = mutableSetOf<String>()
fun refresh() { // OkHttpClient для ping запроса
scope.launch { private val pingClient = OkHttpClient.Builder()
try { .connectTimeout(5, TimeUnit.SECONDS)
val chats = chatRepository.getChats() .readTimeout(5, TimeUnit.SECONDS)
val total = chats.sumOf { it.unreadCount } .build()
activeChatTracker.setTotalUnreadCount(total)
} catch (e: Exception) {
// Ignore load error
}
}
}
fun start() { fun start() {
if (isStarted) return if (isStarted) return
isStarted = true isStarted = true
// Запускаем мониторинг сети
networkManager.startMonitoring()
// Принудительно обновляем состояние сети при старте
networkManager.refreshNetworkState()
// Подключаемся к SignalR при старте приложения
connectSignalR()
// Initial count load // Initial count load
refresh() refresh()
// Запускаем периодическую проверку подключения SignalR
startConnectionHealthCheck()
// Слушаем восстановление сети и переподключаем SignalR
networkManager.isOnline
.filter { it } // Только переход в онлайн
.distinctUntilChanged()
.onEach {
android.util.Log.d("SignalRNtfObserver", "Network restored! Reconnecting SignalR and syncing...")
// Небольшая задержка чтобы сеть стабилизировалась
kotlinx.coroutines.delay(1500)
reconnectOnNetworkRestored()
}
.launchIn(scope)
// Также отслеживаем состояние SignalR для переподключения
signalrClient.status
.filter { it == ConnectionStatus.DISCONNECTED }
.onEach {
android.util.Log.d("SignalRNtfObserver", "SignalR disconnected, checking network...")
// Если мы offline и SignalR отключен - не делаем ничего
// Подключимся когда сеть восстановится
}
.launchIn(scope)
// При восстановлении соединения SignalR обновляем список чатов и вступаем в них
signalrClient.status
.filter { it == ConnectionStatus.CONNECTED }
.distinctUntilChanged()
.onEach {
android.util.Log.d("SignalRNtfObserver", "SignalR connected, refreshing chats and joining rooms")
onSignalRConnected()
}
.launchIn(scope)
// Слушаем события SignalR для уведомлений и обновления списка чатов
android.util.Log.d("SignalRNtfObserver", "Starting to listen to signalrClient.events")
signalrClient.events signalrClient.events
.onEach { event -> .onEach { event ->
android.util.Log.d("SignalRNtfObserver", ">>> Received event: ${event::class.simpleName}")
when (event) { when (event) {
is ChatEvent.NewMessage -> { is ChatEvent.NewMessage -> {
val currentUserId = tokenManager.getUserId() val currentUserId = tokenManager.getUserId()
val message = event.message val message = event.message
android.util.Log.d("SignalRNtfObserver", "New message: ${message.id} from ${message.senderId}, current: $currentUserId")
// Don't show if it's our message or already processed // Don't show if it's our message or already processed
if (message.senderId == currentUserId) return@onEach if (message.senderId == currentUserId) {
if (processedMessageIds.contains(message.id)) return@onEach android.util.Log.d("SignalRNtfObserver", "Skipping - own message")
return@onEach
}
if (processedMessageIds.contains(message.id)) {
android.util.Log.d("SignalRNtfObserver", "Skipping - already processed")
return@onEach
}
// Mark as processed // Mark as processed
processedMessageIds.add(message.id) processedMessageIds.add(message.id)
@@ -70,8 +131,12 @@ class SignalRNotificationObserver @Inject constructor(
refresh() refresh()
// Don't show if this chat is currently open // Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach if (activeChatTracker.currentChatId.value == message.chatId) {
android.util.Log.d("SignalRNtfObserver", "Skipping - chat is open: ${message.chatId}")
return@onEach
}
android.util.Log.d("SignalRNtfObserver", "Showing notification for chat: ${message.chatId}")
NotificationHelper.showNotification( NotificationHelper.showNotification(
context = context, context = context,
title = message.sender?.displayName ?: "Новое сообщение", title = message.sender?.displayName ?: "Новое сообщение",
@@ -83,12 +148,236 @@ class SignalRNotificationObserver @Inject constructor(
) )
} }
is ChatEvent.MessagesRead -> { is ChatEvent.MessagesRead -> {
android.util.Log.d("SignalRNtfObserver", "Messages read event")
// If anyone read messages, sync our total count // If anyone read messages, sync our total count
refresh() refresh()
} }
else -> Unit else -> {
android.util.Log.d("SignalRNtfObserver", "Unhandled event: ${event::class.simpleName}")
Unit
}
} }
} }
.launchIn(scope) .launchIn(scope)
} }
private fun startConnectionHealthCheck() {
// Каждые 10 секунд проверяем подключение и при необходимости переподключаемся
scope.launch {
var consecutiveFailures = 0
while (true) {
kotlinx.coroutines.delay(10000)
val currentStatus = signalrClient.status.value
val isOnline = networkManager.isOnline.value
if (isOnline && currentStatus == ConnectionStatus.DISCONNECTED) {
consecutiveFailures++
android.util.Log.d("SignalRNtfObserver", "Health check: Network online but SignalR disconnected (failures: $consecutiveFailures), reconnecting...")
signalrClient.reconnect()
} else if (isOnline && currentStatus == ConnectionStatus.CONNECTED) {
// Сбрасываем счетчик ошибок при успешном подключении
consecutiveFailures = 0
android.util.Log.d("SignalRNtfObserver", "Health check: Connection healthy")
}
}
}
}
private fun onSignalRConnected() {
android.util.Log.d("SignalRNtfObserver", "SignalR connected - syncing missed messages...")
refresh()
// Вступаем во все чаты для получения событий
joinAllChats()
// Синхронизируем пропущенные сообщения
syncMissedMessages()
}
/**
* Синхронизирует пропущенные сообщения после восстановления соединения
* Запрашивает только НОВЫЕ сообщения с последнего известного sequenceId
*/
private fun syncMissedMessages() {
scope.launch {
try {
android.util.Log.d("SignalRNtfObserver", "Starting missed messages sync...")
val chats = chatRepository.getChats()
android.util.Log.d("SignalRNtfObserver", "Syncing ${chats.size} chats for missed messages")
// Запрашиваем только новые сообщения для каждого чата
chats.forEach { chat ->
try {
// Получаем последний известный sequenceId из локальной базы
val lastSequenceId = chatRepository.getLastKnownSequenceId(chat.id)
if (lastSequenceId != null) {
// Запрашиваем сообщения ПОСЛЕ lastSequenceId (только новые)
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: last known seqId=$lastSequenceId, fetching newer...")
chatRepository.getMessages(
chatId = chat.id,
afterSequenceId = lastSequenceId.toLong(),
limit = 100
)
} else {
// Нет локальных сообщений - загружаем последние 50
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: no local messages, fetching last 50")
chatRepository.getMessages(chatId = chat.id, limit = 50)
}
android.util.Log.d("SignalRNtfObserver", "Synced chat ${chat.id}")
// Небольшая пауза между чатами чтобы не перегружать сервер
kotlinx.coroutines.delay(100)
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to sync chat ${chat.id}", e)
}
}
android.util.Log.d("SignalRNtfObserver", "Missed messages sync completed")
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Sync failed", e)
}
}
}
/**
* Получает последний известный sequenceId для чата из локальной базы
*/
private suspend fun getLastKnownSequenceId(chatId: String): Int? {
return try {
chatRepository.getLastKnownSequenceId(chatId)
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to get last sequenceId for $chatId", e)
null
}
}
private fun reconnectOnNetworkRestored() {
android.util.Log.d("SignalRNtfObserver", "Network restored, starting reconnection sequence...")
scope.launch {
// 1. Сначала делаем HTTP ping запрос чтобы "разбудить" сетевой стек
android.util.Log.d("SignalRNtfObserver", "Sending HTTP ping to wake up network...")
val pingSuccess = sendHttpPing()
android.util.Log.d("SignalRNtfObserver", "HTTP ping result: $pingSuccess")
// 2. Небольшая пауза для стабилизации
kotlinx.coroutines.delay(1000)
// 3. Принудительное переподключение SignalR
android.util.Log.d("SignalRNtfObserver", "Forcing SignalR reconnect...")
signalrClient.reconnect()
// 4. Ждем пока SignalR подключится (максимум 10 секунд)
var waitCount = 0
while (signalrClient.status.value != ConnectionStatus.CONNECTED && waitCount < 20) {
kotlinx.coroutines.delay(500)
waitCount++
}
if (signalrClient.status.value == ConnectionStatus.CONNECTED) {
android.util.Log.d("SignalRNtfObserver", "SignalR reconnected, syncing missed messages...")
// 5. Синхронизируем пропущенные сообщения
syncMissedMessages()
} else {
android.util.Log.w("SignalRNtfObserver", "SignalR failed to reconnect within timeout")
}
// 6. Запускаем синхронизацию отложенных сообщений
MessageSyncWorker.scheduleSync(context)
android.util.Log.d("SignalRNtfObserver", "Outgoing sync worker scheduled")
}
}
/**
* Отправляет HTTP ping запрос для активации сетевого соединения
*/
private suspend fun sendHttpPing(): Boolean {
return try {
val baseUrl = serverConfig.getBaseUrl()
val token = tokenManager.getToken()
if (baseUrl.isBlank() || token == null) {
android.util.Log.w("SignalRNtfObserver", "Cannot ping: missing baseUrl or token")
return false
}
val url = "${baseUrl.removeSuffix("/api/")}/api/auth/refresh"
val request = Request.Builder()
.url(url)
.post(okhttp3.RequestBody.create(null, "{}"))
.addHeader("Authorization", "Bearer $token")
.build()
val response = pingClient.newCall(request).execute()
val success = response.isSuccessful || response.code == 401 // 401 OK для refresh
android.util.Log.d("SignalRNtfObserver", "HTTP ping to $url: ${response.code}")
response.close()
success
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "HTTP ping failed", e)
false
}
}
private fun connectSignalR() {
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
if (token == null || baseUrl.isBlank()) {
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: token=${token != null}, baseUrl=$baseUrl")
return
}
val isOnline = networkManager.isOnline.value
if (!isOnline) {
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: network is offline")
return
}
// Проверяем текущее состояние SignalR
val currentStatus = signalrClient.status.value
if (currentStatus == ConnectionStatus.CONNECTED) {
android.util.Log.d("SignalRNtfObserver", "SignalR already connected, skipping")
return
}
android.util.Log.d("SignalRNtfObserver", "Connecting SignalR with token: ${token.take(10)}..., baseUrl: $baseUrl")
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
// Если подключение не удалось в течение 5 секунд - пробуем снова
scope.launch {
kotlinx.coroutines.delay(5000)
if (signalrClient.status.value == ConnectionStatus.DISCONNECTED) {
android.util.Log.w("SignalRNtfObserver", "SignalR connection timeout, retrying with reconnect()...")
signalrClient.reconnect()
}
}
}
private fun joinAllChats() {
scope.launch {
try {
val chats = chatRepository.getChats()
android.util.Log.d("SignalRNtfObserver", "Joining ${chats.size} chat rooms")
chats.forEach { chat ->
signalrClient.joinChat(chat.id)
}
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to join chats", e)
}
}
}
fun refresh() {
scope.launch {
try {
val chats = chatRepository.getChats()
val total = chats.sumOf { it.unreadCount }
activeChatTracker.setTotalUnreadCount(total)
android.util.Log.d("SignalRNtfObserver", "Refreshed chats: ${chats.size}, total unread: $total")
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to refresh", e)
}
}
}
} }
@@ -140,12 +140,12 @@ class ChatRepositoryImpl @Inject constructor(
} }
override suspend fun getMessages( override suspend fun getMessages(
chatId: String, cursor: String?, pivot: Long?, limit: Int? chatId: String, cursor: String?, pivot: Long?, afterSequenceId: Long?, limit: Int?
): List<Message> { ): List<Message> {
val currentUserId = tokenManager.getUserId() ?: "" val currentUserId = tokenManager.getUserId() ?: ""
return try { return try {
Log.d(TAG, "Fetching messages from API: chatId=$chatId") Log.d(TAG, "Fetching messages from API: chatId=$chatId, afterSequenceId=$afterSequenceId, limit=$limit")
val messages = api.getMessages(chatId, cursor = cursor, limit = limit) val messages = api.getMessages(chatId, cursor = cursor, pivot = pivot, afterSequenceId = afterSequenceId, limit = limit)
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) } val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
@@ -160,6 +160,15 @@ class ChatRepositoryImpl @Inject constructor(
} }
} }
override suspend fun getLastKnownSequenceId(chatId: String): Int? {
return try {
messageDao.getMaxSequenceId(chatId)
} catch (e: Exception) {
Log.e(TAG, "Failed to get last sequenceId", e)
null
}
}
override suspend fun sendMessage( override suspend fun sendMessage(
chatId: String, content: String?, type: String, chatId: String, content: String?, type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?, attachments: List<chats.data.remote.api.AttachmentRequest>?,
@@ -16,7 +16,10 @@ interface ChatRepository {
fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> fun getMessagesPaging(chatId: String): Flow<PagingData<Message>>
// Загрузка из сети (для начальной синхронизации) // Загрузка из сети (для начальной синхронизации)
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message> suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, afterSequenceId: Long? = null, limit: Int? = null): List<Message>
// Получить последний известный sequenceId для чата (из локальной базы)
suspend fun getLastKnownSequenceId(chatId: String): Int?
suspend fun sendMessage( suspend fun sendMessage(
chatId: String, chatId: String,
@@ -4,9 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatEvent import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ConnectionStatus
import chats.data.repository.toDomain import chats.data.repository.toDomain
import chats.domain.model.Message import chats.domain.model.Message
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.network.NetworkManager
import core.network.ServerConfig import core.network.ServerConfig
import core.security.TokenManager import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -59,6 +61,7 @@ class ChatDetailViewModel @Inject constructor(
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val activeChatTracker: core.notifications.data.ActiveChatTracker, private val activeChatTracker: core.notifications.data.ActiveChatTracker,
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver, private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
private val networkManager: NetworkManager,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context @dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() { ) : ViewModel() {
@@ -114,12 +117,39 @@ class ChatDetailViewModel @Inject constructor(
} }
loadChatInfo(chatId) loadChatInfo(chatId)
observeSignalRStatus(chatId)
observeSignalREvents(chatId) observeSignalREvents(chatId)
observeNetworkStatus(chatId)
// Initial sync from network // Initial sync from network
refreshMessages(chatId) refreshMessages(chatId)
} }
private fun observeNetworkStatus(chatId: String) {
// При восстановлении сети обновляем сообщения
networkManager.isOnline
.filter { it } // Только переход в онлайн
.distinctUntilChanged()
.onEach {
android.util.Log.d(TAG, "Network restored in chat detail, refreshing messages")
kotlinx.coroutines.delay(1000) // Дадим сети стабилизироваться
refreshMessages(chatId)
}
.launchIn(viewModelScope)
}
private fun observeSignalRStatus(chatId: String) {
// При переподключении SignalR обновляем сообщения
signalrClient.status
.filter { it == ConnectionStatus.CONNECTED }
.distinctUntilChanged()
.onEach {
android.util.Log.d(TAG, "SignalR connected, refreshing messages for chat $chatId")
refreshMessages(chatId)
}
.launchIn(viewModelScope)
}
private fun updateMessages(messages: List<Message>) { private fun updateMessages(messages: List<Message>) {
val sortedMessages = messages.sortedByDescending { it.sequenceId } val sortedMessages = messages.sortedByDescending { it.sequenceId }
_state.update { it.copy( _state.update { it.copy(
@@ -5,7 +5,9 @@ import androidx.lifecycle.viewModelScope
import chats.domain.model.Chat import chats.domain.model.Chat
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ConnectionStatus
import chats.data.remote.signalr.ChatEvent import chats.data.remote.signalr.ChatEvent
import core.network.NetworkManager
import core.network.ServerConfig import core.network.ServerConfig
import core.security.TokenManager import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -26,52 +28,34 @@ data class ChatListState(
@HiltViewModel @HiltViewModel
class ChatListViewModel @Inject constructor( class ChatListViewModel @Inject constructor(
private val repository: ChatRepository, private val repository: ChatRepository,
private val authRepository: auth.domain.repository.AuthRepository, private val hubClient: ChatHubClient,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig, private val serverConfig: ServerConfig,
private val tokenManager: TokenManager private val tokenManager: TokenManager,
private val networkManager: NetworkManager
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(ChatListState()) private val _state = MutableStateFlow(ChatListState())
val state: StateFlow<ChatListState> = _state.asStateFlow() val state: StateFlow<ChatListState> = _state.asStateFlow()
init { init {
updatePushToken()
val isStoriesEnabled = try {
serverConfig.getServerConfig().features.stories
} catch (e: Exception) {
true
}
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
// Подключаемся к SignalR только если есть токен И введён URL сервера
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
}
loadChats() loadChats()
observeSignalRStatus()
observeSignalREvents() observeSignalREvents()
observeNetworkStatus()
} }
private fun updatePushToken() { private fun observeNetworkStatus() {
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> // При восстановлении сети обновляем чаты
if (task.isSuccessful) { networkManager.isOnline
val token = task.result .filter { it } // Только переход в онлайн
viewModelScope.launch { .distinctUntilChanged()
try { .onEach {
authRepository.updatePushToken(token) android.util.Log.d(TAG, "Network restored in chat list, refreshing chats")
} catch (e: Exception) { kotlinx.coroutines.delay(1000) // Дадим сети стабилизироваться
android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}") repository.getChats()
} }
.launchIn(viewModelScope)
} }
}
}
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: "" private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
@@ -102,6 +86,19 @@ class ChatListViewModel @Inject constructor(
} }
} }
private fun observeSignalRStatus() {
// Наблюдаем за статусом подключения SignalR и обновляем чаты при переподключении
hubClient.status
.filter { it == ConnectionStatus.CONNECTED }
.distinctUntilChanged()
.onEach {
android.util.Log.d(TAG, "SignalR connected, refreshing chats")
// При переподключении обновляем чаты из сети
repository.getChats()
}
.launchIn(viewModelScope)
}
private fun sortChats(chats: List<Chat>): List<Chat> { private fun sortChats(chats: List<Chat>): List<Chat> {
return chats.sortedWith(compareByDescending<Chat> { return chats.sortedWith(compareByDescending<Chat> {
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true) it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
@@ -109,15 +106,17 @@ class ChatListViewModel @Inject constructor(
} }
private fun observeSignalREvents() { private fun observeSignalREvents() {
signalrClient.events val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
android.util.Log.d(TAG, "Starting to observe SignalR events")
hubClient.events
.onEach { event -> .onEach { event ->
android.util.Log.d(TAG, ">>> ChatListVM received event: ${event::class.simpleName}")
when (event) { when (event) {
is ChatEvent.NewMessage -> { is ChatEvent.NewMessage -> {
updateChatsWithNewMessage(event) updateChatsWithNewMessage(event)
} }
is ChatEvent.NewChat -> { is ChatEvent.NewChat -> {
val currentUserId = getCurrentUserId() val currentUserId = getCurrentUserId()
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) } _state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
} }
is ChatEvent.MessagesRead -> { is ChatEvent.MessagesRead -> {
@@ -144,8 +143,13 @@ class ChatListViewModel @Inject constructor(
val currentUserId = getCurrentUserId() val currentUserId = getCurrentUserId()
_state.update { currentState -> _state.update { currentState ->
val updatedChats = currentState.chats.map { chat -> val chatIndex = currentState.chats.indexOfFirst {
if (chat.id.equals(event.message.chatId, ignoreCase = true)) { it.id.equals(event.message.chatId, ignoreCase = true)
}
if (chatIndex >= 0) {
// Чат есть в списке - обновляем его
val chat = currentState.chats[chatIndex]
val isMyMessage = event.message.senderId == currentUserId val isMyMessage = event.message.senderId == currentUserId
val isAlreadySeen = chat.lastMessage?.id == event.message.id val isAlreadySeen = chat.lastMessage?.id == event.message.id
@@ -160,14 +164,26 @@ class ChatListViewModel @Inject constructor(
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount") android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
} }
chat.copy( val updatedChat = chat.copy(
lastMessage = lastMsgDomain, lastMessage = lastMsgDomain,
unreadCount = newCount unreadCount = newCount
) )
} else chat
}
val updatedChats = currentState.chats.toMutableList()
updatedChats[chatIndex] = updatedChat
currentState.copy(chats = sortChats(updatedChats)) currentState.copy(chats = sortChats(updatedChats))
} else {
// Чата нет в списке - обновляем весь список из репозитория
android.util.Log.d("ChatListVM", "Chat ${event.message.chatId} not found in list, refreshing from repository")
viewModelScope.launch {
try {
repository.getChats() // Это обновит Room и Flow
} catch (e: Exception) {
android.util.Log.e("ChatListVM", "Failed to refresh chats", e)
}
}
currentState
}
} }
} }
} }
@@ -0,0 +1,152 @@
package core.network
import android.content.Context
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Менеджер состояния сети
* Отслеживает подключение к интернету через NetworkCallback + BroadcastReceiver
*/
@Singleton
class NetworkManager @Inject constructor(
private val context: Context
) {
private val _isOnline = MutableStateFlow(isNetworkAvailable())
val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
private var networkReceiver: NetworkReceiver? = null
private var isMonitoring = false
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d("NetworkManager", "Network available - callback")
_isOnline.value = true
}
override fun onLost(network: Network) {
Log.d("NetworkManager", "Network lost - callback")
_isOnline.value = false
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val hasValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
Log.d("NetworkManager", "Capabilities changed: hasInternet=$hasInternet, hasValidated=$hasValidated")
_isOnline.value = hasInternet && hasValidated
}
override fun onUnavailable() {
Log.d("NetworkManager", "Network unavailable - callback")
_isOnline.value = false
}
}
fun startMonitoring() {
if (isMonitoring) {
Log.w("NetworkManager", "Already monitoring, skipping")
return
}
isMonitoring = true
val cm = connectivityManager ?: run {
Log.e("NetworkManager", "ConnectivityManager is null")
return
}
try {
// 1. Регистрируем NetworkCallback для активного отслеживания
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.build()
cm.registerNetworkCallback(networkRequest, networkCallback)
Log.d("NetworkManager", "Registered NetworkCallback")
// 2. Регистрируем BroadcastReceiver как запасной механизм
// (на Android 10+ работает только для foreground приложений)
val receiver = NetworkReceiver(this)
networkReceiver = receiver
val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
try {
@Suppress("DEPRECATION")
context.registerReceiver(receiver, filter)
Log.d("NetworkManager", "Registered NetworkReceiver")
} catch (e: Exception) {
Log.w("NetworkManager", "Failed to register BroadcastReceiver: ${e.message}")
}
// 3. Инициализируем текущее состояние
_isOnline.value = isNetworkAvailable()
Log.d("NetworkManager", "Initial network state: ${_isOnline.value}")
} catch (e: Exception) {
Log.e("NetworkManager", "Error setting up network monitoring", e)
_isOnline.value = isNetworkAvailable()
}
}
fun stopMonitoring() {
if (!isMonitoring) return
isMonitoring = false
try {
connectivityManager?.unregisterNetworkCallback(networkCallback)
Log.d("NetworkManager", "Unregistered NetworkCallback")
} catch (e: Exception) {
Log.e("NetworkManager", "Error unregistering NetworkCallback", e)
}
try {
networkReceiver?.let {
context.unregisterReceiver(it)
it.cleanup()
networkReceiver = null
Log.d("NetworkManager", "Unregistered NetworkReceiver")
}
} catch (e: Exception) {
Log.e("NetworkManager", "Error unregistering NetworkReceiver", e)
}
}
private fun isNetworkAvailable(): Boolean {
return try {
val network = connectivityManager?.activeNetwork
val capabilities = connectivityManager?.getNetworkCapabilities(network)
val available = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
Log.d("NetworkManager", "isNetworkAvailable check: $available")
available
} catch (e: Exception) {
Log.e("NetworkManager", "Error checking network", e)
false
}
}
fun notifyNetworkRestored() {
Log.d("NetworkManager", "Network restored notified - forcing state update")
_isOnline.value = true
}
/**
* Принудительно проверяет текущее состояние сети и уведомляет подписчиков
*/
fun refreshNetworkState() {
val currentState = isNetworkAvailable()
Log.d("NetworkManager", "Refreshed network state: $currentState")
_isOnline.value = currentState
}
}
@@ -0,0 +1,68 @@
package core.network
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* BroadcastReceiver для надежного отслеживания изменений сети
* Используется как дополнение к NetworkCallback для лучшей надежности
*/
@Singleton
class NetworkReceiver @Inject constructor(
private val networkManager: NetworkManager
) : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var lastKnownState: Boolean = false
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ConnectivityManager.CONNECTIVITY_ACTION) {
val isConnected = isNetworkAvailable(context)
Log.d("NetworkReceiver", "Broadcast received: isConnected=$isConnected, lastKnown=$lastKnownState")
// Отправляем уведомление только если состояние изменилось
if (isConnected != lastKnownState) {
lastKnownState = isConnected
scope.launch {
if (isConnected) {
Log.d("NetworkReceiver", "Network connected - notifying NetworkManager")
// Небольшая задержка для стабилизации сети
kotlinx.coroutines.delay(500)
networkManager.notifyNetworkRestored()
} else {
Log.d("NetworkReceiver", "Network disconnected")
}
}
}
}
}
private fun isNetworkAvailable(context: Context): Boolean {
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = cm?.activeNetwork
val capabilities = cm?.getNetworkCapabilities(network)
capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
} catch (e: Exception) {
Log.e("NetworkReceiver", "Error checking network", e)
false
}
}
fun cleanup() {
scope.cancel()
}
}