diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml index 44008a43..f0f34af3 100644 --- a/common/src/main/AndroidManifest.xml +++ b/common/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/common/src/main/java/com/project200/common/utils/NetworkMonitor.kt b/common/src/main/java/com/project200/common/utils/NetworkMonitor.kt new file mode 100644 index 00000000..7b0977ed --- /dev/null +++ b/common/src/main/java/com/project200/common/utils/NetworkMonitor.kt @@ -0,0 +1,55 @@ +package com.project200.common.utils + +import android.Manifest +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import androidx.annotation.RequiresPermission +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkMonitor + @Inject + constructor( + @ApplicationContext context: Context, + ) { + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // 현재 인터넷 연결 여부 확인 + @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE) + fun isCurrentlyConnected(): Boolean { + val activeNetwork = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + // 네트워크 상태 변경 감지 Flow + val networkState = + callbackFlow { + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + } + + val request = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + } + } diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml index 44008a43..f0f34af3 100644 --- a/data/src/main/AndroidManifest.xml +++ b/data/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/api/ChatApiService.kt b/data/src/main/java/com/project200/data/api/ChatApiService.kt new file mode 100644 index 00000000..26c43c51 --- /dev/null +++ b/data/src/main/java/com/project200/data/api/ChatApiService.kt @@ -0,0 +1,13 @@ +package com.project200.data.api + +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.TicketResponse +import retrofit2.http.POST +import retrofit2.http.Path + +interface ChatApiService { + @POST("api/v1/chat-rooms/{chatroomId}/ticket") + suspend fun getChatTicket( + @Path("chatroomId") chatroomId: Long, + ): BaseResponse +} diff --git a/data/src/main/java/com/project200/data/di/NetworkModule.kt b/data/src/main/java/com/project200/data/di/NetworkModule.kt index 93b9b6ad..9a319b05 100644 --- a/data/src/main/java/com/project200/data/di/NetworkModule.kt +++ b/data/src/main/java/com/project200/data/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.project200.data.di import com.project200.data.api.ApiService +import com.project200.data.api.ChatApiService import com.project200.data.utils.FcmTokenProvider import com.project200.data.utils.LocalDateAdapter import com.project200.data.utils.LocalDateTimeAdapter @@ -71,6 +72,7 @@ object NetworkModule { .addInterceptor(tokenInterceptor) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() } @@ -100,4 +102,10 @@ object NetworkModule { fun provideApiService(retrofit: Retrofit): ApiService { return retrofit.create(ApiService::class.java) } + + @Provides + @Singleton + fun provideChatApiService(retrofit: Retrofit): ChatApiService { + return retrofit.create(ChatApiService::class.java) + } } diff --git a/data/src/main/java/com/project200/data/di/RepositoryModule.kt b/data/src/main/java/com/project200/data/di/RepositoryModule.kt index 7ec6f901..e5d411a8 100644 --- a/data/src/main/java/com/project200/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/project200/data/di/RepositoryModule.kt @@ -3,6 +3,7 @@ package com.project200.data.di import com.project200.data.impl.AddressRepositoryImpl import com.project200.data.impl.AppUpdateRepositoryImpl import com.project200.data.impl.AuthRepositoryImpl +import com.project200.data.impl.ChatSocketRepositoryImpl import com.project200.data.impl.ChattingRepositoryImpl import com.project200.data.impl.ExerciseRecordRepositoryImpl import com.project200.data.impl.FcmRepositoryImpl @@ -15,6 +16,7 @@ import com.project200.data.impl.TimerRepositoryImpl import com.project200.domain.repository.AddressRepository import com.project200.domain.repository.AppUpdateRepository import com.project200.domain.repository.AuthRepository +import com.project200.domain.repository.ChatSocketRepository import com.project200.domain.repository.ChattingRepository import com.project200.domain.repository.ExerciseRecordRepository import com.project200.domain.repository.FcmRepository @@ -77,6 +79,10 @@ abstract class RepositoryModule { @Singleton abstract fun bindChattingRepository(chattingRepositoryImpl: ChattingRepositoryImpl): ChattingRepository + @Binds + @Singleton + abstract fun bindChatSocketRepository(chatSocketRepositoryImpl: ChatSocketRepositoryImpl): ChatSocketRepository + @Binds @Singleton abstract fun bindNotificationRepository(notificationRepositoryImpl: NotificationRepositoryImpl): NotificationRepository diff --git a/data/src/main/java/com/project200/data/dto/ChattingDTO.kt b/data/src/main/java/com/project200/data/dto/ChattingDTO.kt index 841e8147..d30d7136 100644 --- a/data/src/main/java/com/project200/data/dto/ChattingDTO.kt +++ b/data/src/main/java/com/project200/data/dto/ChattingDTO.kt @@ -1,5 +1,7 @@ package com.project200.data.dto +import com.project200.domain.model.SocketType +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.time.LocalDateTime @@ -62,3 +64,36 @@ data class PostMessageResponse( data class PostChatMessageRequest( val content: String, ) + +// 티켓 발급 응답 +@JsonClass(generateAdapter = true) +data class TicketResponse( + @Json(name = "chatTicket") + val chatTicket: String, +) + +@JsonClass(generateAdapter = true) +data class SocketChatMessage( + @Json(name = "webSocketType") + val type: SocketType, + val data: SocketChatMessageDTO? = null, +) + +@JsonClass(generateAdapter = true) +data class SocketChatMessageDTO( + val chatId: Long, + val senderId: String?, + val senderNickname: String?, + val senderProfileUrl: String?, + val senderThumbnailUrl: String?, + val chatContent: String, + val chatType: String, + val sentAt: LocalDateTime, +) + +@JsonClass(generateAdapter = true) +data class SocketChatRequest( + @Json(name = "webSocketType") + val type: SocketType, + val content: String?, +) diff --git a/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt new file mode 100644 index 00000000..a878b697 --- /dev/null +++ b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt @@ -0,0 +1,248 @@ +package com.project200.data.impl + +import com.project200.common.utils.NetworkMonitor +import com.project200.data.api.ChatApiService +import com.project200.data.dto.SocketChatMessage +import com.project200.data.dto.SocketChatRequest +import com.project200.data.local.PreferenceManager +import com.project200.data.mapper.toModel +import com.project200.domain.model.ChattingMessage +import com.project200.domain.model.SocketType +import com.project200.domain.repository.ChatSocketRepository +import com.project200.undabang.data.BuildConfig +import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import timber.log.Timber +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.pow + +@Singleton +class ChatSocketRepositoryImpl + @Inject + constructor( + private val okHttpClient: OkHttpClient, + private val chatApi: ChatApiService, + private val moshi: Moshi, + private val networkMonitor: NetworkMonitor, + private val spManager: PreferenceManager, + ) : ChatSocketRepository { + private val requestAdapter = moshi.adapter(SocketChatRequest::class.java) + private val responseAdapter = moshi.adapter(SocketChatMessage::class.java) + + private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val connectionMutex = Mutex() + + private var webSocket: WebSocket? = null + + private val _incomingMessages = MutableSharedFlow() + override val incomingMessages = _incomingMessages.asSharedFlow() + + private val memberId = spManager.getMemberId().toString() + private var currentChatRoomId: Long = -1L + private var isUserInChatRoom = false + private var retryCount = AtomicInteger(0) + + private var heartbeatJob: Job? = null + private var retryJob: Job? = null + + // 네트워크 복구 감지 + init { + CoroutineScope(Dispatchers.IO).launch { + networkMonitor.networkState.collect { isConnected -> + if (isActive && isConnected && isUserInChatRoom) { + retryCount.set(0) + connectSocketInternal(currentChatRoomId) + } + } + } + } + + /** + * 채팅방에 소켓 연결 + */ + override fun connect(chatRoomId: Long) { + currentChatRoomId = chatRoomId + isUserInChatRoom = true + connectSocketInternal(chatRoomId) + } + + /** + * 채팅방 소켓 연결 해제 + */ + override fun disconnect() { + isUserInChatRoom = false + stopHeartbeat() + retryJob?.cancel() + retryJob = null + webSocket?.close(1000, "User Exit") + webSocket = null + } + + /** + * 메시지 전송 + */ + override fun sendMessage(content: String) { + val payload = SocketChatRequest(SocketType.TALK, content) + val json = requestAdapter.toJson(payload) + webSocket?.send(json) + Timber.d("Sent message: $json") + } + + /** + * 소켓 연결 처리 + */ + private fun connectSocketInternal(chatRoomId: Long) { + repositoryScope.launch { + connectionMutex.withLock { + if (webSocket != null) { // 이미 연결된 경우 + Timber.d("WebSocket already connected.") + return@withLock + } + try { + // 티켓 발급 + val response = chatApi.getChatTicket(chatRoomId) + val ticket = + response.data?.chatTicket + ?: throw Exception("Ticket issuance failed") + + // 소켓 연결 + val wsUrl = + if (BuildConfig.DEBUG) { + "$BASE_URL_DEBUG$ticket" + } else { + "$BASE_URL_RELEASE$ticket" + } + + val request = Request.Builder().url(wsUrl).build() + webSocket = okHttpClient.newWebSocket(request, socketListener) + } catch (e: Exception) { + handleConnectionFailure(e) + } + } + } + } + + /** + * 소켓 리스너 + */ + private val socketListener = + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + retryCount.set(0) + startApplicationHeartbeat() + } + + // 메시지 수신 + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + repositoryScope.launch { + try { + val wrapper = responseAdapter.fromJson(text) ?: return@launch + // TALK 타입 메시지 처리 + // PING/PONG은 별도 처리 없음 + if (wrapper.type == SocketType.TALK && wrapper.data != null) { + val message = wrapper.data.toModel().copy(isMine = wrapper.data.senderId == memberId) + Timber.d("Received TALK message: $text \n $memberId") + _incomingMessages.emit(message) + } else if (wrapper.type == SocketType.PONG) { + Timber.d("Received PONG from server") + } + } catch (e: Exception) { + Timber.e(e, "Socket Message Parsing Error. Text: $text") + } + } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + cleanupAndRetry(t) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + if (code != 1000) cleanupAndRetry(Exception("Closed: $reason")) + } + } + + private fun cleanupAndRetry(t: Throwable) { + stopHeartbeat() + webSocket = null + handleConnectionFailure(t) + } + + // 지수 백오프 재연결 + private fun handleConnectionFailure(t: Throwable) { + if (!isUserInChatRoom || !networkMonitor.isCurrentlyConnected()) return + + // 이전 재시도 작업 취소 후 재연결 시도 + retryJob?.cancel() + retryJob = + repositoryScope.launch { + val currentRetry = retryCount.getAndIncrement() + val delayMs = (2.0.pow(currentRetry) * 1000).toLong().coerceAtMost(MAX_RETRY_DELAY_MS) + + delay(delayMs) + // 지연 시간 이후에도 여전히 방에 있는지 확인 + if (isActive && isUserInChatRoom) { + connectSocketInternal(currentChatRoomId) + } + } + } + + // 애플리케이션 하트비트 시작 + private fun startApplicationHeartbeat() { + stopHeartbeat() + heartbeatJob = + CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + delay(PING_INTERVAL_MS) // 30초마다 PING + val pingPayload = + SocketChatRequest( + SocketType.PING, + content = null, + ) + webSocket?.send(requestAdapter.toJson(pingPayload)) + } + } + } + + private fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + companion object { + private const val PING_INTERVAL_MS = 30_000L + private const val MAX_RETRY_DELAY_MS = 10_000L + private const val BASE_URL_DEBUG = "wss://dev-chat.undabang.store/ws/chat?chatTicket=" + private const val BASE_URL_RELEASE = "wss://chat.undabang.store/ws/chat?chatTicket=" + } + } diff --git a/data/src/main/java/com/project200/data/mapper/ChatSocketMapper.kt b/data/src/main/java/com/project200/data/mapper/ChatSocketMapper.kt new file mode 100644 index 00000000..7d765703 --- /dev/null +++ b/data/src/main/java/com/project200/data/mapper/ChatSocketMapper.kt @@ -0,0 +1,18 @@ +package com.project200.data.mapper + +import com.project200.data.dto.SocketChatMessageDTO +import com.project200.domain.model.ChattingMessage + +fun SocketChatMessageDTO.toModel(): ChattingMessage { + return ChattingMessage( + chatId = this.chatId, + senderId = this.senderId, + nickname = this.senderNickname, + profileUrl = this.senderProfileUrl, + thumbnailImageUrl = this.senderThumbnailUrl, + content = this.chatContent, + chatType = this.chatType, + sentAt = this.sentAt, + isMine = false, + ) +} diff --git a/domain/src/main/java/com/project200/domain/model/ChattingModel.kt b/domain/src/main/java/com/project200/domain/model/ChattingModel.kt index 2b73ff0c..9b3666f2 100644 --- a/domain/src/main/java/com/project200/domain/model/ChattingModel.kt +++ b/domain/src/main/java/com/project200/domain/model/ChattingModel.kt @@ -22,7 +22,7 @@ data class ChattingMessage( val content: String, val chatType: String, val sentAt: LocalDateTime, - val isMine: Boolean, + val isMine: Boolean = false, val showProfile: Boolean = false, // 프로필 표시 여부 (상대방 메시지용) val showTime: Boolean = false // 시간 표시 여부 ) @@ -32,4 +32,8 @@ data class ChattingModel( val opponentActive: Boolean, val blockActive: Boolean, val messages: List -) \ No newline at end of file +) + +enum class SocketType { + PING, TALK, ERROR, PONG +} diff --git a/domain/src/main/java/com/project200/domain/repository/ChatSocketRepository.kt b/domain/src/main/java/com/project200/domain/repository/ChatSocketRepository.kt new file mode 100644 index 00000000..7cf493d3 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/repository/ChatSocketRepository.kt @@ -0,0 +1,11 @@ +package com.project200.domain.repository + +import com.project200.domain.model.ChattingMessage +import kotlinx.coroutines.flow.SharedFlow + +interface ChatSocketRepository { + val incomingMessages: SharedFlow + fun connect(chatRoomId: Long) + fun disconnect() + fun sendMessage(content: String) +} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/ConnectChatRoomUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/ConnectChatRoomUseCase.kt new file mode 100644 index 00000000..e1cea601 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/ConnectChatRoomUseCase.kt @@ -0,0 +1,12 @@ +package com.project200.domain.usecase + +import com.project200.domain.repository.ChatSocketRepository +import javax.inject.Inject + +class ConnectChatRoomUseCase @Inject constructor( + private val chatSocketRepository: ChatSocketRepository +) { + operator fun invoke(chatRoomId: Long) { + chatSocketRepository.connect(chatRoomId) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/DisconnectChatRoomUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DisconnectChatRoomUseCase.kt new file mode 100644 index 00000000..9e17a435 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/DisconnectChatRoomUseCase.kt @@ -0,0 +1,12 @@ +package com.project200.domain.usecase + +import com.project200.domain.repository.ChatSocketRepository +import javax.inject.Inject + +class DisconnectChatRoomUseCase @Inject constructor( + private val chatSocketRepository: ChatSocketRepository +) { + operator fun invoke() { + chatSocketRepository.disconnect() + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/ObserveSocketMessagesUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/ObserveSocketMessagesUseCase.kt new file mode 100644 index 00000000..a40662b2 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/ObserveSocketMessagesUseCase.kt @@ -0,0 +1,14 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.ChattingMessage +import com.project200.domain.repository.ChatSocketRepository +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +class ObserveSocketMessagesUseCase @Inject constructor( + private val chatSocketRepository: ChatSocketRepository +) { + operator fun invoke(): SharedFlow { + return chatSocketRepository.incomingMessages + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/SendSocketMessageUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/SendSocketMessageUseCase.kt new file mode 100644 index 00000000..5b06f2a5 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/SendSocketMessageUseCase.kt @@ -0,0 +1,12 @@ +package com.project200.domain.usecase + +import com.project200.domain.repository.ChatSocketRepository +import javax.inject.Inject + +class SendSocketMessageUseCase @Inject constructor( + private val chatSocketRepository: ChatSocketRepository +) { + operator fun invoke(content: String) { + chatSocketRepository.sendMessage(content) + } +} \ No newline at end of file diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt index b20dcc5e..e69dc43f 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt @@ -35,8 +35,6 @@ import com.project200.presentation.utils.UiUtils.dpToPx import com.project200.undabang.feature.chatting.R import com.project200.undabang.feature.chatting.databinding.FragmentChattingRoomBinding import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.time.LocalDate import javax.inject.Inject @@ -200,15 +198,6 @@ class ChattingRoomFragment : BindingFragment(R.layo override fun setupObservers() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - // Fragment가 STARTED 상태가 되면 폴링 시작 - // STOPPED 상태가 되면 자동으로 코루틴 취소 - while (isActive && viewModel.chatState.value != ChatInputState.OpponentBlocked) { - viewModel.getNewMessages() - delay(POLLING_PERIOD) - } - } - launch { viewModel.chatState.collect { state -> val isEnabled: Boolean @@ -393,6 +382,7 @@ class ChattingRoomFragment : BindingFragment(R.layo super.onResume() // 현재 채팅방을 활성 채팅방으로 설정 chatRoomStateRepository.setActiveChatRoomId(args.roomId) + viewModel.connectAndSync() } override fun onPause() { @@ -401,6 +391,7 @@ class ChattingRoomFragment : BindingFragment(R.layo if (chatRoomStateRepository.activeChatRoomId.value == args.roomId) { chatRoomStateRepository.setActiveChatRoomId(null) } + viewModel.disconnect() } companion object { diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt index 40ab5f07..55d98a04 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt @@ -2,13 +2,15 @@ package com.project200.feature.chatting.chattingRoom import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.project200.common.utils.ClockProvider import com.project200.domain.model.BaseResult import com.project200.domain.model.ChattingMessage +import com.project200.domain.usecase.ConnectChatRoomUseCase +import com.project200.domain.usecase.DisconnectChatRoomUseCase import com.project200.domain.usecase.ExitChatRoomUseCase import com.project200.domain.usecase.GetChatMessagesUseCase import com.project200.domain.usecase.GetNewChatMessagesUseCase -import com.project200.domain.usecase.SendChatMessageUseCase +import com.project200.domain.usecase.ObserveSocketMessagesUseCase +import com.project200.domain.usecase.SendSocketMessageUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -24,9 +26,11 @@ class ChattingRoomViewModel constructor( private val getChatMessagesUseCase: GetChatMessagesUseCase, private val getNewChatMessagesUseCase: GetNewChatMessagesUseCase, - private val sendChatMessageUseCase: SendChatMessageUseCase, private val exitChatRoomUseCase: ExitChatRoomUseCase, - private val clockProvider: ClockProvider, + private val connectChatRoomUseCase: ConnectChatRoomUseCase, + private val disconnectChatRoomUseCase: DisconnectChatRoomUseCase, + private val observeSocketMessagesUseCase: ObserveSocketMessagesUseCase, + private val sendSocketMessageUseCase: SendSocketMessageUseCase, ) : ViewModel() { private val _messages = MutableStateFlow>(emptyList()) val messages = _messages.asStateFlow() @@ -46,6 +50,16 @@ class ChattingRoomViewModel private var lastChatId: Long? = null // 새 메시지 조회를 위한 마지막 메시지 ID var hasNextMessages: Boolean = true // 더 로드할 메시지가 있는지 여부 + init { + viewModelScope.launch { + // TODO: 내 memberId 저장 + observeSocketMessagesUseCase().collect { chat -> + // 리스트에 추가 + addMessage(chat) + } + } + } + fun setId( chatRoomId: Long, opponentId: String, @@ -55,10 +69,35 @@ class ChattingRoomViewModel loadInitialMessages(chatRoomId) } + // 소켓 연결 및 공백 채우기 + fun connectAndSync() { + if (chatRoomId == DEFAULT_ID) return + // 소켓 연결 시도 + connectChatRoomUseCase(chatRoomId) + // 소켓이 끊겨있던 동안 온 메시지 가져오기 + syncMissedMessages() + } + + fun disconnect() { + disconnectChatRoomUseCase() + } + + // 메시지 추가 + private fun addMessage(newMessage: ChattingMessage) { + val currentList = _messages.value + if (currentList.none { it.content == newMessage.content && it.sentAt == newMessage.sentAt }) { + updateAndEmitMessages(currentList + newMessage) + } + } + + // 메시지 목록 업데이트 및 방출 private fun updateAndEmitMessages(updatedList: List) { _messages.value = processMessagesForGrouping(updatedList) } + /** + * 메시지 그룹화 처리 + */ private fun processMessagesForGrouping(messages: List): List { if (messages.isEmpty()) return emptyList() @@ -99,13 +138,7 @@ class ChattingRoomViewModel prevChatId = chattingModel.messages.firstOrNull()?.chatId // 가장 오래된 메시지의 ID를 저장 lastChatId = chattingModel.messages.lastOrNull()?.chatId - _chatState.emit( - when { - chattingModel.blockActive -> ChatInputState.OpponentBlocked - !chattingModel.opponentActive -> ChatInputState.OpponentLeft - else -> ChatInputState.Active - }, - ) + updateChatState(chattingModel.blockActive, chattingModel.opponentActive) } is BaseResult.Error -> { _toast.emit(result.message.toString()) @@ -115,9 +148,9 @@ class ChattingRoomViewModel } /** - * 새 메시지를 받아오는 폴링 함수 + * 소켓 연결이 끊겨있는 사이 온 새 메시지를 받아오는 함수 */ - fun getNewMessages() { + fun syncMissedMessages() { if (chatRoomId == DEFAULT_ID) return viewModelScope.launch { when (val result = getNewChatMessagesUseCase(chatRoomId, lastChatId)) { @@ -131,20 +164,12 @@ class ChattingRoomViewModel !currentMessages.any { it.chatId == newMessage.chatId } } if (uniqueNewMessages.isNotEmpty()) { + // 마지막 메시지 ID 업데이트 lastChatId = uniqueNewMessages.lastOrNull()?.chatId updateAndEmitMessages(currentMessages + uniqueNewMessages) } } - val currentState = _chatState.value - val newChatState = - when { - result.data.blockActive -> ChatInputState.OpponentBlocked - !result.data.opponentActive -> ChatInputState.OpponentLeft - else -> ChatInputState.Active - } - if (currentState != newChatState) { - _chatState.value = newChatState - } + updateChatState(result.data.blockActive, result.data.opponentActive) } is BaseResult.Error -> { @@ -161,33 +186,8 @@ class ChattingRoomViewModel if (text.isBlank()) return if (chatRoomId == DEFAULT_ID) return - val messageToSend = - ChattingMessage( - chatId = DEFAULT_ID, // 임시 아이디 - senderId = "my_user_id", - nickname = "나", - profileUrl = null, - thumbnailImageUrl = null, - content = text, - chatType = "USER", - sentAt = clockProvider.localDateTimeNow(), - isMine = true, - ) - viewModelScope.launch { - when (val result = sendChatMessageUseCase(chatRoomId, text)) { - is BaseResult.Success -> { - // 서버로부터 받은 chatId로 설정 - val confirmedMessage = - messageToSend.copy( - chatId = result.data, - ) - updateAndEmitMessages(_messages.value + confirmedMessage) - } - is BaseResult.Error -> { - _toast.emit(result.message.toString()) - } - } + sendSocketMessageUseCase(text) } } @@ -215,12 +215,32 @@ class ChattingRoomViewModel } } + /** + * 채팅 상태 업데이트 + */ + private fun updateChatState( + blockActive: Boolean, + opponentActive: Boolean, + ) { + _chatState.value = + when { + blockActive -> ChatInputState.OpponentBlocked + !opponentActive -> ChatInputState.OpponentLeft + else -> ChatInputState.Active + } + } + fun exitChatRoom() { viewModelScope.launch { _exitResult.emit(exitChatRoomUseCase(chatRoomId)) } } + override fun onCleared() { + super.onCleared() + disconnect() + } + companion object { const val DEFAULT_ID = -1L const val LOAD_SIZE = 30 // 초기 로드 메시지 개수