878 lines
92 KiB
Markdown
878 lines
92 KiB
Markdown
# DM 채팅화면 구현 계획/TASK
|
|
|
|
## 1. 목표
|
|
`docs/20260610_DM_채팅화면/prd.md`를 기준으로 신규 DM 채팅방 상세 화면을 v2 패키지 하위에 구현한다. 기존 AI `ChatRoomActivity`는 직접 수정하지 않고, DM 전용 Activity, ViewModel, Repository, DTO, WebSocket 클라이언트, 메시지 UI 모델을 최소 범위로 추가한다.
|
|
|
|
## 2. 구현 결정 사항
|
|
- 신규 화면은 `kr.co.vividnext.sodalive.v2.main.chat.dm` 하위에 둔다.
|
|
- DM 채팅방 Activity 이름은 `DmChatRoomActivity`로 한다.
|
|
- intent extra는 `EXTRA_ROOM_ID`, `EXTRA_CREATOR_ID`를 사용한다.
|
|
- `roomId > 0`: `OpenRoom`부터 시작한다.
|
|
- `roomId <= 0 && creatorId > 0`: `CreateOrGetRoom` 호출 후 반환된 `roomId`로 `OpenRoom`을 호출한다.
|
|
- 둘 다 유효하지 않으면 Activity를 종료한다.
|
|
- REST API는 기존 v2 채팅 탭과 동일하게 Retrofit + RxJava3 + `ApiResponse<T>` 패턴을 사용한다.
|
|
- Phase 1~8은 기존 SSE 기반 구현 이력으로 보존한다. WebSocket 전환은 Phase 9부터 기존 SSE 구현을 교체하는 후속 범위로 진행한다.
|
|
- WebSocket은 `OkHttpClient.newWebSocket()` 기반 전용 `DmChatSocketClient`를 추가하고, 기존 `DmChatEventClient`/SSE parser는 WebSocket 전환 완료 후 사용하지 않는다.
|
|
- WebSocket 연결/해제는 Activity foreground 범위와 로그아웃 흐름에서 처리한다.
|
|
- `onStart`: `OpenRoom` 완료 후 연결 가능 상태면 WebSocket 연결을 시작하고 `JOIN_ROOM`을 보낸다.
|
|
- `JOINED`: 실시간 수신 가능 상태로 판단한다.
|
|
- `onStop`/화면 이탈/앱 background/로그아웃: `LEAVE_ROOM` 전송 후 socket close를 수행한다.
|
|
- `onDestroy`/`onCleared`: listener 참조, heartbeat, reconnect 예약, socket을 정리한다.
|
|
- WebSocket 재연결 후에는 `JOIN_ROOM`을 다시 보내고, 필요하면 `GetMessages`로 최신 누락 가능 메시지를 동기화한다.
|
|
- 네트워크 오류로 WebSocket이 실패하면 화면이 foreground에 있고 채팅방이 활성 상태인 경우에만 재연결을 시도한다.
|
|
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 `GetMessages`로 누락 가능 메시지를 보정한다.
|
|
- 화면 이탈 또는 background 전환 시 예약된 재연결 시도는 취소한다.
|
|
- `VOICE` 메시지는 DTO에 보존하되 이번 UI 목록에는 표시하지 않는다.
|
|
- 전송은 낙관적 UI를 적용한다.
|
|
- 전송 직후 local pending 메시지를 추가한다.
|
|
- WebSocket `SEND_ACK` 수신 시 `requestId`로 pending 메시지를 찾아 서버 `messageId`, `createdAt`, 프로필 정보로 확정한다.
|
|
- `ERROR` 또는 timeout 시 실패 상태와 재시도 버튼을 표시한다.
|
|
- Phase 9 이후 텍스트 전송 정책은 `requestId` 단위 pending map으로 관리한다.
|
|
- 서로 다른 텍스트 메시지는 각각의 `requestId`로 독립 pending 상태를 가질 수 있다.
|
|
- 같은 UI item 재시도는 새 `requestId`를 발급하되 기존 local item을 유지한다.
|
|
- Phase 3 ViewModel의 pagination/reconnect 동기화 실패는 화면 종료나 Error 화면 전환 없이 기존 메시지 상태를 유지하고 내부 loading 상태만 복구한다.
|
|
- 사용자 노출 toast/retry UI는 Phase 5 Activity 연결 시 필요하면 별도 처리한다.
|
|
|
|
## 3. 파일 구조
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
|
- DM 채팅방 화면, intent 진입, RecyclerView/input/header/lifecycle/WebSocket 연결 제어를 담당한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- 방 생성/열기, pagination, WebSocket 메시지 전송/수신, leave/close 상태를 관리한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
|
- `user-creator-chat` REST endpoint를 정의한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
|
|
- REST DTO와 서버 메시지 DTO를 정의한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
|
- REST API 호출 래핑, token 전달, WebSocket 클라이언트 위임을 담당한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
|
|
- 기존 SSE 연결 구현이다. Phase 9 이후 WebSocket 전환 완료 시 신규 경로에서 사용하지 않으며 제거 또는 미사용 상태로 둔다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
|
- OkHttp WebSocket 연결, handshake header, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, 수신 callback, close 처리를 담당한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
|
- WebSocket envelope, payload, `requestId` 기반 `SEND_ACK`, `ERROR`, `MESSAGE`, `PONG` 모델을 정의한다.
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
|
- WebSocket handshake header, endpoint, send/receive envelope, close 동작을 검증한다.
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt`
|
|
- `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`, 알 수 없는 type 파싱을 검증한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt`
|
|
- UI 메시지 모델, 전송 상태, 화면 상태를 정의한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt`
|
|
- 서버 DTO를 UI 모델로 변환하고 정렬/중복 제거 helper를 제공한다.
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt`
|
|
- 내 메시지/상대 메시지 ViewHolder, 실패 재시도 callback을 담당한다.
|
|
- Create: `app/src/main/res/layout/activity_dm_chat_room.xml`
|
|
- AI 채팅방 layout을 기준으로 DM 전용 header, message list, input 영역을 구성한다.
|
|
- Create: `app/src/main/res/layout/item_dm_chat_my_message.xml`
|
|
- 내 텍스트 메시지, 전송 중/실패 상태, 재시도 버튼을 표시한다.
|
|
- Create: `app/src/main/res/layout/item_dm_chat_opponent_message.xml`
|
|
- 상대 텍스트 메시지와 프로필/닉네임을 표시한다.
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
|
|
- DM item 클릭 시 `DmChatRoomActivity.newIntentByRoomId()`로 이동한다.
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
|
- `DmChatApi`, `DmChatRepository`, `DmChatSocketClient`, `DmChatRoomViewModel` DI를 등록한다.
|
|
- Modify: `app/src/main/AndroidManifest.xml`
|
|
- `DmChatRoomActivity`를 등록한다.
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt`
|
|
- 푸시 payload의 `chat_type`, `room_id`를 `DeepLinkActivity`로 전달한다.
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
|
|
- `chat_type == "USER_CREATOR"`와 `room_id` 기준으로 DM 채팅방 진입 intent를 만든다.
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
|
|
- 로그인/메인 진입 후 전달된 DM 푸시 extras를 DM 채팅방 진입으로 연결한다.
|
|
- Modify: `docs/agent-guides/build-test-style.md`
|
|
- 신규 DM 채팅 테스트 단일 실행 예시를 추가한다.
|
|
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt`
|
|
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt`
|
|
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventParserTest.kt`
|
|
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
|
|
## 4. 성공 기준
|
|
- `roomId` 기반 진입은 create API 없이 `OpenRoom`을 호출한다.
|
|
- `creatorId` 기반 진입은 `CreateOrGetRoom` 성공 후 반환된 `roomId`로 `OpenRoom`을 호출한다.
|
|
- OpenRoom 메시지는 오래된 순서에서 최신 순서로 표시된다.
|
|
- 메시지 병합은 `messageId` 기준으로 중복을 제거한다.
|
|
- 상단 스크롤 시 `hasMore=true`, `nextCursor != null`, `isLoading=false` 조건에서만 과거 메시지를 조회한다.
|
|
- 텍스트 전송은 blank 입력을 무시하고, WebSocket `SEND_TEXT`와 `requestId` pending 매칭을 사용한다.
|
|
- 전송 실패 메시지는 `ERROR` 또는 timeout 기준으로 실패 상태와 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
|
|
- 화면 stop/destroy/background/logout 흐름에서 `LEAVE_ROOM` 전송과 socket close가 화면 종료를 막지 않는다.
|
|
- 제거된 `GET /api/v2/user-creator-chat/rooms/{roomId}/events`, `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`, 텍스트 전송용 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`는 신규 텍스트 실시간 송수신 경로에서 호출하지 않는다.
|
|
- 음성 메시지는 기존 multipart REST API 경로를 유지한다.
|
|
- DM 화면에는 `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`가 없다.
|
|
- `ChatRoomActivity` 기존 동작은 변경하지 않는다.
|
|
|
|
---
|
|
|
|
### Phase 1: API/모델/매퍼 기반 추가
|
|
|
|
- [x] **Task 1.1: REST DTO와 API 정의**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
|
|
- 작업:
|
|
- `CreateDmChatRoomRequest`, `CreateDmChatRoomResponse`를 추가한다.
|
|
- `DmChatRoomOpenResponse`, `DmChatMessagesPageResponse`, `DmChatMessageResponse`, `SendDmTextMessageRequest`, `SendDmChatMessageResponse`를 추가한다.
|
|
- Retrofit endpoint를 아래 계약으로 추가한다.
|
|
- `POST /api/v2/user-creator-chat/rooms/create`
|
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/open`
|
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/messages`
|
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
|
- 검증:
|
|
- DTO 필드명은 PRD의 서버 필드명을 `@SerializedName`으로 그대로 보존한다.
|
|
- REST 반환 타입은 `Single<ApiResponse<...>>`를 사용한다.
|
|
|
|
- [x] **Task 1.2: UI 모델과 메시지 병합 helper 추가**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt`
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt`
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt`
|
|
- 작업:
|
|
- `DmChatMessageUiItem`에 `messageId`, `localId`, `mine`, `textMessage`, `senderNickname`, `senderProfileImageUrl`, `createdAt`, `status`를 둔다.
|
|
- `DmChatMessageStatus`는 `SENDING`, `SENT`, `FAILED`로 정의한다.
|
|
- `messageType`은 서버 계약상 `TEXT`/`VOICE` 대문자이나, UI 매핑에서는 오입력 방지를 위해 대소문자를 무시해 `TEXT`를 판정한다.
|
|
- `messageType`이 `TEXT`가 아니거나 `textMessage == null`이면 UI item으로 매핑하지 않는다.
|
|
- `sortByCreatedAtAndMessageId()`와 `mergeByMessageId()` helper를 추가한다.
|
|
- 정렬은 `createdAt` 오름차순 후 같은 시각에서는 `messageId` 오름차순을 따른다.
|
|
- 중복 `messageId` 병합은 선도착한 기존 item을 유지하고 후도착 중복 item을 버린다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"`
|
|
- Expected: `TEXT` 대소문자 무시 매핑, `TEXT` 외 타입과 `textMessage == null` 제외, `createdAt`/`messageId` 오름차순 정렬, 중복 `messageId` 선도착 우선 테스트가 PASS.
|
|
|
|
### Phase 2: Repository와 SSE 클라이언트 추가
|
|
|
|
- [x] **Task 2.1: Repository 추가**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
|
- 작업:
|
|
- `createOrGetRoom(token, creatorId)`, `openRoom(token, roomId, limit)`, `getMessages(token, roomId, cursor, limit)`, `sendTextMessage(token, roomId, textMessage)`, `disconnectRealtime(token, roomId)`를 추가한다.
|
|
- `limit` 기본값은 `20`으로 둔다.
|
|
- `Authorization` 헤더 문자열은 Repository 내부의 단일 helper로 만든다. 예: `private fun bearer(token: String) = "Bearer $token"`.
|
|
- 검증:
|
|
- Repository는 API 호출을 얇게 위임하고 별도 비즈니스 로직을 넣지 않는다.
|
|
- 모든 REST API 호출은 동일한 bearer helper를 통해 생성된 auth header를 사용한다.
|
|
|
|
- [x] **Task 2.2: OkHttp 기반 SSE 클라이언트 추가**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventParserTest.kt`
|
|
- 작업:
|
|
- `connect(token, roomId, listener)`는 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 요청을 만든다.
|
|
- header는 REST와 동일하게 `Authorization: Bearer ...`를 전달한다.
|
|
- `connected` 이벤트는 메시지 목록에 전달하지 않고 연결 상태 callback만 호출한다.
|
|
- `message` 이벤트 data는 Gson으로 `DmChatMessageResponse`로 파싱한다.
|
|
- `cancel()`은 진행 중인 `Call`을 cancel하고 listener 참조를 해제한다.
|
|
- stream read와 cancel은 UI thread를 블로킹하지 않도록 OkHttp callback thread에서 처리한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventParserTest"`
|
|
- Expected: `connected` 이벤트, 단일 `message` 이벤트, 여러 줄 SSE frame 파싱, 잘못된 JSON 무시 또는 failure callback 테스트가 PASS.
|
|
|
|
### Phase 3: ViewModel 상태와 단위 테스트 추가
|
|
|
|
- [x] **Task 3.1: ViewModel 초기 진입 흐름 구현**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `enter(roomId, creatorId)`를 추가한다.
|
|
- `roomId > 0`이면 `openRoom`을 호출한다.
|
|
- `roomId <= 0 && creatorId > 0`이면 `createOrGetRoom` 후 `openRoom`을 호출한다.
|
|
- `roomId <= 0 && creatorId <= 0`이면 종료 이벤트를 발행한다.
|
|
- OpenRoom 성공 시 header 정보와 메시지 목록, `hasMore`, `nextCursor`를 상태에 반영한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: roomId 진입, creatorId 진입, invalid 진입, OpenRoom 정렬 반영 테스트가 PASS.
|
|
|
|
- [x] **Task 3.2: pagination 상태 구현**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt`
|
|
- 작업:
|
|
- `loadOlderMessages()`를 추가한다.
|
|
- `hasMore=false`, `isLoadingOlder=true`, `nextCursor=null`이면 요청하지 않는다.
|
|
- 성공 응답 메시지는 기존 목록 상단에 prepend하고 `messageId` 기준 중복을 제거한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"`
|
|
- Expected: 요청 조건, cursor 전달, prepend, 스크롤 보정용 추가 개수 반환 테스트가 PASS.
|
|
|
|
- [x] **Task 3.3: 전송/재시도/SSE 반영 상태 구현**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `sendText(text)`는 trim 후 blank면 종료한다.
|
|
- 전송 중 같은 local message에 대한 중복 요청을 막는다.
|
|
- 전송 직후 `SENDING` local item을 추가한다.
|
|
- 성공 시 local item을 서버 메시지로 교체한다.
|
|
- 실패 시 local item status를 `FAILED`로 변경한다.
|
|
- `retry(localId)`는 실패 item의 text를 다시 전송한다.
|
|
- `onRealtimeMessage(message)`는 `messageId` 중복을 제거하고 최신 메시지로 append한다.
|
|
- 재연결 후 동기화는 `syncLatestMessagesAfterReconnect()`로 분리해 `GetMessages` 호출과 병합을 수행한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: blank 무시, pending 추가, 성공 교체, 실패 상태, retry 성공, SSE 중복 제거 테스트가 PASS.
|
|
|
|
### Phase 4: UI 레이아웃과 Adapter 구현
|
|
|
|
- [x] **Task 4.1: DM 채팅방 layout 생성**
|
|
- Files:
|
|
- Create: `app/src/main/res/layout/activity_dm_chat_room.xml`
|
|
- 작업:
|
|
- `activity_chat_room.xml` 구조를 참고해 배경 이미지, dim, header, `rv_messages`, `input_container`를 구성한다.
|
|
- header에는 `iv_back`, `iv_profile`, `tv_name`만 둔다.
|
|
- `rv_messages` top constraint는 `header_container` 하단으로 연결한다.
|
|
- `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`는 추가하지 않는다.
|
|
- 검증:
|
|
- XML에서 제거 대상 id가 검색되지 않아야 한다.
|
|
- Run: `rg "character_type_badge|ll_can_badge|iv_more|notice_container" app/src/main/res/layout/activity_dm_chat_room.xml`
|
|
- Expected: 결과 없음.
|
|
|
|
- [x] **Task 4.2: 메시지 item layout과 Adapter 구현**
|
|
- Files:
|
|
- Create: `app/src/main/res/layout/item_dm_chat_my_message.xml`
|
|
- Create: `app/src/main/res/layout/item_dm_chat_opponent_message.xml`
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt`
|
|
- 작업:
|
|
- 내 메시지는 오른쪽 정렬, 상대 메시지는 왼쪽 정렬로 구성한다.
|
|
- 내 실패 메시지에는 재시도 버튼 또는 클릭 가능한 실패 상태 view를 둔다.
|
|
- `DiffUtil`과 stable id는 `messageId` 우선, local pending 메시지는 `localId` 기준으로 처리한다.
|
|
- 상대 프로필 이미지는 기존 placeholder 정책에 맞춰 `ic_placeholder_profile`을 사용한다.
|
|
- 검증:
|
|
- 긴 텍스트가 item 너비 안에서 줄바꿈되는지 XML `maxWidth` 또는 constraint를 확인한다.
|
|
|
|
### Phase 5: Activity 구현과 화면 연결
|
|
|
|
- [x] **Task 5.1: DmChatRoomActivity 생성**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
|
- 작업:
|
|
- `newIntentByRoomId(context, roomId)`와 `newIntentByCreatorId(context, creatorId)`를 제공한다.
|
|
- ViewBinding으로 `activity_dm_chat_room.xml`을 연결한다.
|
|
- header, RecyclerView, input, IME send, send button enable/disable을 설정한다.
|
|
- 상단 도달 시 `viewModel.loadOlderMessages()`를 호출한다.
|
|
- prepend 후 기존 스크롤 위치를 유지한다.
|
|
- 사용자가 하단 근처에 있을 때만 새 메시지 수신 후 하단으로 스크롤한다.
|
|
- 검증:
|
|
- `ChatRoomActivity`의 쿼터/광고/더보기/notice 관련 로직을 가져오지 않는다.
|
|
|
|
- [x] **Task 5.2: SSE lifecycle과 disconnect 연결**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- 작업:
|
|
- `onStart`에서 OpenRoom 완료 상태이면 `viewModel.connectRealtime()`를 호출한다.
|
|
- `onStop`에서 `viewModel.disconnectRealtime()`를 호출한다.
|
|
- disconnect 요청 중복을 막는 `isDisconnecting` 상태를 둔다.
|
|
- disconnect 실패는 화면 종료를 막지 않고 toast를 과하게 노출하지 않는다.
|
|
- 검증:
|
|
- ViewModel 테스트에서 disconnect 중복 방지와 cancel 호출 여부를 검증한다.
|
|
|
|
- [x] **Task 5.3: 채팅 탭 DM item 클릭 연결**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
|
|
- 작업:
|
|
- `ChatRoomType.AI`는 기존 `ChatRoomActivity`로 이동한다.
|
|
- `ChatRoomType.DM`은 `DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)`로 이동한다.
|
|
- 검증:
|
|
- AI item 클릭 동작은 기존과 동일하게 유지한다.
|
|
|
|
- [x] **Task 5.4: Phase 5 리뷰 관찰 항목 정리**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `bindContent`의 모든 Content emit마다 `connectRealtime()`를 호출하는 흐름을 점검한다.
|
|
- 기능 변경이 과도하지 않으면 OpenRoom 완료 후 연결 가능 상태 진입 시점에만 realtime connect를 트리거하는 별도 신호로 분리한다.
|
|
- SSE 실패 후 자동 재연결은 PRD 범위에 포함되므로, `onFailure` 이후 foreground/활성 채팅방 상태일 때 서버 `reconnectTime=3000`ms 기준으로 재연결을 예약한다.
|
|
- 재연결 성공 후 `Last-Event-ID` 기반 replay는 기대하지 않고 `GetMessages`로 누락 가능 메시지를 보정한다.
|
|
- 화면 이탈 또는 background 전환 시 예약된 재연결을 취소해 종료 후 재연결이 발생하지 않도록 한다.
|
|
- `disconnectRealtime()` 진행 중 빠른 `onStart` 재진입 시 crash 위험이 없음을 guard 조합과 테스트로 확인한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: realtime connect 호출 의도가 테스트 또는 source test로 확인되고, SSE 실패 후 3초 지연 재연결, 재연결 후 최신 메시지 동기화, 화면 이탈 시 예약 재연결 취소, disconnect 중 재진입이 crash로 이어지지 않는 정책이 확인된다.
|
|
|
|
- [x] **Task 5.5: 자동 재연결 실행 스레드 race 제거**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `reconnectScheduler.scheduleDirect { connectRealtime(token) }`처럼 io scheduler에서 직접 `connectRealtime()`를 실행하는 흐름을 제거한다.
|
|
- 지연 예약은 기존 scheduler를 사용하되, 실제 `connectRealtime(token)` 호출과 realtime mutable flag 변경은 main thread에서 수행되도록 `scheduleRealtimeCallback { connectRealtime(token) }` 또는 main scheduler 관찰로 옮긴다.
|
|
- `isRealtimeConnected`, `shouldReconnectRealtime`, `reconnectDisposable`, `currentRealtimeToken` 변경 스레드가 main thread 기준으로 일관되는지 확인한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: SSE failure 후 예약된 재연결이 main thread에서 `connectRealtime()`를 실행하고, background/io thread에서 realtime mutable flag를 직접 변경하지 않는다.
|
|
|
|
- [x] **Task 5.6: disconnect와 예약 재연결 경합 방지**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- 예약된 재연결 람다가 실행을 시작한 직후 `disconnectRealtime()`가 호출되는 경우를 점검한다.
|
|
- `connectRealtime()` 진입부에서 `shouldReconnectRealtime` 또는 foreground/활성 채팅방 상태를 재확인해 disconnect 이후 재연결이 살아남지 않도록 한다.
|
|
- `disconnectRealtime()`의 예약 취소와 local realtime 정리 순서가 기존 중복 disconnect API guard와 충돌하지 않는지 확인한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: 예약 재연결 실행 직전/직후 disconnect가 호출되어도 새 SSE 연결이 남지 않고, disconnect API 중복 방지 동작은 유지된다.
|
|
|
|
- [x] **Task 5.7: SSE 재연결 backoff 또는 시도 제한 검토**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- Modify: `docs/20260610_DM_채팅화면/plan-task.md`
|
|
- 작업:
|
|
- foreground 한정 3초 무한 재시도가 PRD의 서버 `reconnectTime=3000`ms 기준과 충돌하지 않는지 검토한다.
|
|
- 지속 실패 상황의 네트워크 부담을 줄이기 위해 지수 backoff 또는 최대 시도 횟수 제한 중 최소 변경안을 선택한다.
|
|
- backoff/시도 제한을 적용하는 경우, 재연결 성공 또는 수동 재진입 시 재시도 상태가 초기화되도록 한다.
|
|
- PRD 범위와 충돌하거나 정책 결정이 필요하면 구현하지 않고 결정 필요 사항을 문서에 남긴다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: 선택한 재연결 정책이 테스트로 고정되고, PRD의 3초 기본 간격 및 foreground 한정 조건을 깨지 않는다.
|
|
|
|
- [x] **Task 5.8: roomOpenedEventLiveData 스티키 재전달 방지**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `roomOpenedEventLiveData`가 일반 `MutableLiveData<Boolean>`로 마지막 `true`를 재구독자에게 재전달하는지 확인한다.
|
|
- 단발성 이벤트에는 기존 프로젝트 패턴에 맞는 SingleLiveEvent, Event wrapper, consume flag 중 최소 변경 방식을 적용한다.
|
|
- 화면 회전 또는 observer 재등록 시 `connectRealtimeIfStarted()`가 이벤트 재전달만으로 다시 호출되지 않도록 한다.
|
|
- 기존 `connectRealtime()` idempotent guard는 유지하되, 단발성 이벤트 자체의 의미를 명확히 한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: OpenRoom 완료 이벤트는 한 번만 소비되고, observer 재등록만으로 realtime connect 트리거가 반복되지 않는다.
|
|
|
|
### Phase 6: DI, Manifest, 문서 갱신
|
|
|
|
- [x] **Task 6.1: Koin DI 등록**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
|
- 작업:
|
|
- `DmChatApi` API builder 등록을 추가한다.
|
|
- `DmChatEventClient`는 기존 `OkHttpClient`, `Gson`, `BuildConfig.BASE_URL` 기반으로 생성되도록 등록한다.
|
|
- `DmChatRepository`, `DmChatRoomViewModel` 등록을 추가한다.
|
|
- 검증:
|
|
- import 추가 외 기존 DI 등록 순서를 불필요하게 재정렬하지 않는다.
|
|
|
|
- [x] **Task 6.2: Manifest 등록**
|
|
- Files:
|
|
- Modify: `app/src/main/AndroidManifest.xml`
|
|
- 작업:
|
|
- `.v2.main.chat.dm.DmChatRoomActivity`를 application 하위 activity 목록에 추가한다.
|
|
- 키보드 UX는 기존 채팅방과 유사하게 `android:windowSoftInputMode="stateAlwaysHidden|adjustResize"`를 우선 적용한다.
|
|
- 검증:
|
|
- activity는 exported를 명시하지 않는 기존 내부 Activity 패턴을 따른다.
|
|
|
|
- [x] **Task 6.3: 테스트 실행 가이드 갱신**
|
|
- Files:
|
|
- Modify: `docs/agent-guides/build-test-style.md`
|
|
- 작업:
|
|
- DM 채팅 테스트 단일 실행 예시를 추가한다.
|
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`
|
|
- 검증:
|
|
- 문서 변경은 신규 테스트 명령 예시 추가로만 제한한다.
|
|
|
|
- [x] **Task 6.4: SSE 전용 read timeout 제거**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventClientTest.kt`
|
|
- 작업:
|
|
- Phase 6 DI 등록 시 `DmChatEventClient`에 주입되는 `OkHttpClient` 인스턴스를 확인한다.
|
|
- 공유 `OkHttpClient`의 일반 `readTimeout`이 idle SSE stream을 조기 종료하지 않도록 SSE 전용 client를 `okHttpClient.newBuilder().readTimeout(0, TimeUnit.MILLISECONDS).build()`로 생성한다.
|
|
- REST API용 공유 client timeout 정책은 변경하지 않는다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`
|
|
- Expected: SSE 전용 client의 read timeout이 0으로 설정되고, 기존 SSE parsing/cancel/failure 동작은 유지된다.
|
|
|
|
- [x] **Task 6.5: realtime callback scheduling Disposable 누적 방지**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `scheduleRealtimeCallback()`이 SSE message마다 완료된 `Disposable`을 `CompositeDisposable`에 계속 누적하는 패턴을 제거한다.
|
|
- 권장 우선순위는 `Handler(Looper.getMainLooper()).post { }`로 main thread에 전달하는 방식이다.
|
|
- Rx scheduler를 유지해야 한다면 완료 후 자기 자신을 `CompositeDisposable`에서 제거하는 방식으로 누적을 방지한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: SSE callback은 main thread에서 처리되고, 메시지 수신 횟수만큼 완료된 `Disposable`이 누적되지 않는다.
|
|
|
|
- [x] **Task 6.6: ViewModel onCleared 정리 보장**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `DmChatRoomViewModel`에 `onCleared()` 오버라이드를 추가한다.
|
|
- ViewModel 소멸 후 `mainHandler.post { action() }`로 예약된 realtime callback이 실행되지 않도록 `mainHandler.removeCallbacksAndMessages(null)`를 호출한다.
|
|
- `compositeDisposable.clear()` 또는 `dispose()`, `reconnectDisposable.dispose()`, `realtimeClient.cancel()` 정리가 `onCleared()`에서도 보장되는지 확인하고 누락된 정리를 추가한다.
|
|
- Activity `onStop`/`onDestroy` 경로에만 의존하지 않고 ViewModel lifecycle 종료만으로 realtime 연결과 예약 작업이 정리되도록 한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
|
|
- Expected: `onCleared()` 이후 예약된 main callback이 상태를 갱신하지 않고, composite/reconnect disposable 및 realtime client cancel 정리가 호출된다.
|
|
|
|
- [x] **Task 6.7: DmChatEventClientTest reflection 의존 제거 검토**
|
|
- Files:
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventClientTest.kt`
|
|
- 작업:
|
|
- `getDeclaredField("okHttpClient")`로 내부 필드를 읽는 검증을 동작 기반 검증으로 대체할 수 있는지 확인한다.
|
|
- 최소 변경으로 가능하면 `MockWebServer` 또는 실제 request/stream 동작을 통해 SSE 전용 read timeout 정책과 기존 parsing/cancel/failure 동작을 검증한다.
|
|
- reflection 제거가 과도한 테스트 구조 변경을 요구하면 현재 검증은 유지하되, 필드명 변경에 취약한 낮은 우선순위 테스트 부채로 문서에 남긴다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`
|
|
- Expected: 내부 필드명에 직접 의존하지 않거나, 유지 사유가 명확히 기록되고 기존 SSE 전용 client 동작 검증은 유지된다.
|
|
|
|
### Phase 7: 최종 검증과 기록
|
|
|
|
- [x] **Task 7.1: 단위 테스트 실행**
|
|
- Files:
|
|
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/*Test.kt`
|
|
- Run:
|
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`
|
|
- Expected:
|
|
- 신규 DM 채팅 단위 테스트가 모두 PASS.
|
|
|
|
- [x] **Task 7.2: 앱 빌드 확인**
|
|
- Files:
|
|
- Check: Gradle project
|
|
- Run:
|
|
- `./gradlew :app:assembleDebug`
|
|
- Expected:
|
|
- Debug APK 빌드 PASS.
|
|
|
|
- [x] **Task 7.3: 린트/스타일 확인**
|
|
- Files:
|
|
- Check: Kotlin/XML 변경 파일
|
|
- Run:
|
|
- `./gradlew :app:ktlintCheck`
|
|
- Expected:
|
|
- ktlint PASS.
|
|
|
|
- [ ] **Task 7.4: 수동 확인**
|
|
- Files:
|
|
- Check: `DmChatRoomActivity`
|
|
- 확인 항목:
|
|
- DM item 클릭 시 DM 채팅방 화면으로 이동한다.
|
|
- header에 뒤로가기, 상대 프로필, 상대 닉네임만 표시된다.
|
|
- 메시지 목록은 header 바로 아래에서 시작한다.
|
|
- blank 입력은 전송되지 않는다.
|
|
- 텍스트 전송 실패 시 실패 상태와 재시도 버튼이 표시된다.
|
|
- Phase 9~13 WebSocket 전환 후 화면 이탈 또는 앱 background 전환 시 `LEAVE_ROOM` 전송 후 socket close가 호출된다.
|
|
- Phase 9~13 WebSocket 전환 후 WebSocket 연결 실패가 앱 crash로 이어지지 않는다.
|
|
|
|
### Phase 8: 크리에이터 채널 DM 진입 crash 수정
|
|
|
|
- [x] **Task 8.1: creatorId 기반 진입 thread crash 재현 테스트 추가**
|
|
- Files:
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `DmChatRoomActivity.newIntentByCreatorId()`로 들어오는 흐름에 대응해 `enter(roomId = 0L, creatorId > 0L)` 테스트를 보강한다.
|
|
- `CreateOrGetRoom` 이후 `OpenRoom` 결과가 background scheduler에서 전달되어도 `LiveData` 상태 갱신이 main thread에서 처리되어야 함을 고정한다.
|
|
- 기존 roomId 기반 진입 테스트는 유지하고, creatorId 기반 진입만의 Rx chain thread 전환 문제를 분리해 검증한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: 수정 전에는 background thread `MutableLiveData.setValue()` 예외 또는 main thread 보장 assertion으로 RED를 확인하고, 수정 후 PASS한다.
|
|
- 검증 기록:
|
|
- 2026-06-17: `DmChatRoomViewModelTest`에 `creatorId 진입은 openRoom 결과 처리 전에 main thread로 다시 전환한다` 테스트를 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행 결과 해당 테스트가 `DmChatRoomViewModelTest.kt:126` assertion failure로 RED가 되었고, 현재 `createRoomAndOpen()`에는 `flatMap` 이후 `OpenRoom` 결과 처리 전 main thread 재전환이 없음을 확인했다.
|
|
|
|
- [x] **Task 8.2: createRoomAndOpen main thread 전환 보장**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- 작업:
|
|
- `createRoomAndOpen()`의 `CreateOrGetRoom` 후 `OpenRoom` 연속 호출 흐름에서 최종 결과 처리 전에 main thread 전환이 보장되는지 점검한다.
|
|
- `handleOpenRoomResult()`, `handleError()`, `_roomOpenedEventLiveData` 갱신, `emitContent()` 호출이 main thread에서 실행되도록 최소 변경을 적용한다.
|
|
- `openRoom(roomId)` 단독 진입, pagination, send/retry, SSE callback scheduling, reconnect/disconnect 정리 정책은 변경하지 않는다.
|
|
- `postValue()`로 증상을 숨기기보다 기존 ViewModel의 `setValue` 기반 동기 상태 갱신 의미를 유지할 수 있는 scheduler 위치를 우선 검토한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: creatorId 기반 진입에서 `Cannot invoke setValue on a background thread` 예외 없이 Content 상태와 room opened event가 발행된다.
|
|
- 검증 기록:
|
|
- 2026-06-17: `DmChatRoomViewModel.createRoomAndOpen()`의 `flatMap` 뒤에 `observeOn(AndroidSchedulers.mainThread())`를 추가해 `OpenRoom` 결과 처리 전 main thread 전환을 보장했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`을 실행해 Task 8.1에서 RED였던 테스트를 포함한 `DmChatRoomViewModelTest` 전체 PASS를 확인했다.
|
|
|
|
- [x] **Task 8.3: DM 채팅 회귀 테스트와 빌드 확인**
|
|
- Files:
|
|
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/*Test.kt`
|
|
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- 작업:
|
|
- DM 채팅 ViewModel 변경이 mapper, repository, SSE parser/client, pagination 상태 테스트를 깨지 않는지 확인한다.
|
|
- 가능하면 debug 빌드와 ktlint를 순차 실행해 Gradle cache 경합을 피한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`
|
|
- Run: `./gradlew :app:compileDebugKotlin --max-workers=1`
|
|
- Run: `./gradlew :app:ktlintCheck --max-workers=1`
|
|
- Expected: 모두 PASS. 기존 `.editorconfig` `disabled_rules` deprecation warning은 실패로 보지 않는다.
|
|
- 검증 기록:
|
|
- 2026-06-17: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`를 순차 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
|
|
- [x] **Task 8.4: 수동 확인 항목 갱신**
|
|
- Files:
|
|
- Check: `DmChatRoomActivity`
|
|
- Check: `CreatorChannelActivity`
|
|
- 확인 항목:
|
|
- 크리에이터 채널에서 `DM 보내기`를 터치하면 `DmChatRoomActivity`로 이동한다.
|
|
- `creatorId` 기반 진입 후 방 생성/조회와 OpenRoom 결과 반영 중 앱이 crash 되지 않는다.
|
|
- header 상대 정보와 초기 메시지 목록이 표시된다.
|
|
- 채팅 탭의 기존 `roomId` 기반 DM 진입은 기존처럼 동작한다.
|
|
|
|
### Phase 9: WebSocket 계약/클라이언트 기반 추가
|
|
|
|
- [x] **Task 9.1: WebSocket envelope와 payload 모델 정의**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt`
|
|
- 작업:
|
|
- WebSocket 공통 envelope는 `type`과 `payload`를 분리해 파싱할 수 있게 정의한다.
|
|
- Client send type은 `JOIN_ROOM`, `LEAVE_ROOM`, `SEND_TEXT`, `PING`을 지원한다.
|
|
- Server receive type은 `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`을 지원한다.
|
|
- `JOIN_ROOM`/`LEAVE_ROOM` payload는 현재 `roomId`를 포함한다.
|
|
- `SEND_TEXT` payload는 `roomId`, `requestId`, `textMessage`를 포함한다.
|
|
- `SEND_ACK` payload는 `requestId`와 서버 확정 메시지(`DmChatMessageResponse`)를 포함한다.
|
|
- `MESSAGE` payload는 서버 메시지(`DmChatMessageResponse`)를 포함한다.
|
|
- `ERROR` payload는 `requestId`가 있을 수도 있고 없을 수도 있으므로 nullable로 모델링하고, message/code 필드를 보존한다.
|
|
- 서버의 정확한 JSON field name은 백엔드 계약과 대조해 확정하되, Android 모델은 서버 field name을 `@SerializedName`으로 보존한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1`
|
|
- Expected: `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`, 알 수 없는 type, 잘못된 JSON 파싱 테스트가 PASS.
|
|
- 검증 기록:
|
|
- 2026-06-18: `DmChatSocketParserTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1` 실행 시 `DmChatSocketEvent`/`DmChatSocketParser` 미정의 컴파일 오류로 RED를 확인했다. 이후 `DmChatSocketModels.kt`에 `type`/`payload` envelope, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` client type, `JOINED`/`MESSAGE`/`SEND_ACK`/`ERROR`/`PONG` parser 모델을 추가했다. 재실행 결과 `DmChatSocketParserTest` PASS를 확인했고, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`도 PASS했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
- 2026-06-18: 리뷰에서 `ERROR`의 `code`/`message` nullable 보존 테스트가 부족하다는 지적을 받아 `code=null`, `message=null` 케이스를 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1` 실행 결과 `ERROR type은 nullable code와 message를 보존한다` 테스트가 RED로 실패함을 확인했고, `DmChatSocketErrorPayload`와 `DmChatSocketEvent.Error`의 `code`/`message`를 nullable로 변경한 뒤 같은 테스트가 PASS했다. 이후 `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1` 모두 PASS를 확인했다.
|
|
- 2026-06-18: 코드 리뷰로 `DmChatSocketModels.kt`와 `DmChatSocketParserTest.kt`를 재검토했다. `type`/`payload` envelope, `JOINED`/`MESSAGE`/`SEND_ACK`/`ERROR`/`PONG` 파싱, nullable `ERROR` 필드 보존, 알 수 없는 type/잘못된 JSON 무시 동작이 Task 9.1 범위와 일치함을 확인했고 추가 결함은 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1` PASS를 확인했다.
|
|
|
|
- [x] **Task 9.2: OkHttp WebSocket 클라이언트 추가**
|
|
- Files:
|
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
|
- 작업:
|
|
- `DmChatSocketClient`는 `OkHttpClient.newWebSocket()`으로 `${BuildConfig.BASE_URL}`의 scheme을 WebSocket scheme으로 변환해 `/ws/v2/user-creator-chat`에 연결한다.
|
|
- `https://`는 `wss://`, `http://`는 `ws://`로 변환한다.
|
|
- handshake request에 `Authorization: Bearer <accessToken>` header를 추가한다.
|
|
- `connect(token, listener)`는 socket 연결만 수행하고, 방 참여는 ViewModel이 `sendJoinRoom(roomId)`로 명시 호출한다.
|
|
- `sendJoinRoom(roomId)`, `sendLeaveRoom(roomId)`, `sendText(roomId, requestId, textMessage)`, `sendPing()`를 제공한다.
|
|
- `close()`는 진행 중 socket을 close하고 listener 참조를 해제한다.
|
|
- `onMessage`는 `DmChatSocketModels` parser로 envelope를 파싱해 listener callback으로 전달한다.
|
|
- 알 수 없는 type 또는 파싱 실패는 앱 crash 없이 listener failure 또는 ignored event로 처리한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1`
|
|
- Expected: endpoint URL 변환, Authorization header, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` JSON 송신, 수신 callback, close 정리 테스트가 PASS.
|
|
- 검증 기록:
|
|
- 2026-06-18: `DmChatSocketClientTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1` 실행 시 `DmChatSocketClient` 미정의 컴파일 오류로 RED를 확인했다. 이후 `DmChatSocketClient.kt`를 추가해 `https`/`http` base URL의 WebSocket scheme 변환, `Authorization: Bearer ...` handshake header, 명시적 `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, parser 기반 수신 event callback, 알 수 없는 type/잘못된 JSON 무시, close 정리를 구현했다. 구현 직후 URL scheme 검증 2건이 RED로 남아 OkHttp `Request.url` 정규화 특성을 반영해 원본 WebSocket URL을 request tag로 보존하도록 조정했고, 재실행 결과 `DmChatSocketClientTest` PASS를 확인했다. `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`도 PASS했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
- 2026-06-18: 코드 리뷰로 `DmChatSocketClient.kt`와 `DmChatSocketClientTest.kt`를 재검토했다. WebSocket endpoint scheme 변환, bearer header, 명시적 `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, 현재 socket guard, close 정리, parser event 전달이 Task 9.2 범위와 일치함을 확인했고 추가 결함은 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1` PASS를 확인했다.
|
|
|
|
- [x] **Task 9.3: Repository와 DI를 WebSocket 클라이언트 기준으로 전환**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
|
- 작업:
|
|
- `DmChatRepository`는 `connectRealtime()`/`cancelRealtime()` seam을 WebSocket 클라이언트 위임으로 교체하거나 `connectSocket()`/`closeSocket()`처럼 의미가 분명한 이름으로 변경한다.
|
|
- REST `createOrGetRoom`, `openRoom`, `getMessages`는 유지한다.
|
|
- 텍스트 전송용 REST `sendTextMessage()`와 `disconnectRealtime()` repository method는 신규 전송/해제 경로에서 사용하지 않도록 제거하거나 deprecated 없이 삭제한다.
|
|
- `AppDI.kt`는 `DmChatEventClient` 대신 `DmChatSocketClient`를 등록한다.
|
|
- `Authorization` header 생성은 기존 `bearer(token)` helper를 재사용해 REST와 WebSocket의 bearer 문자열이 일관되도록 한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1`
|
|
- Expected: REST create/open/messages는 기존처럼 동작하고, WebSocket connect/send/close 위임과 bearer header 생성 테스트가 PASS.
|
|
- 검증 기록:
|
|
- 2026-06-18: `DmChatRepository`와 `AppDI`를 `DmChatSocketClient` 기준으로 전환하고 REST `createOrGetRoom`/`openRoom`/`getMessages` 경로는 유지됨을 확인했다. 대상 실행으로 `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`가 PASS했고, `./gradlew :app:compileDebugKotlin --max-workers=1` 및 style 정리 후 `./gradlew :app:ktlintCheck --max-workers=1`도 PASS했다. `ktlintCheck`에는 기존 `.editorconfig`의 `disabled_rules` deprecation warning만 남았다.
|
|
- 2026-06-18: Phase 9 코드 리뷰로 `DmChatRepository`, `AppDI`, `DmChatRoomViewModel`, `DmChatRoomActivity`의 WebSocket 전환 diff를 재검토했다. `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`는 PASS했다. 다만 `DmChatRoomViewModel`이 `JOINED` 수신 전 `isRealtimeConnected=true`로 처리하고, 전송 제어가 아직 단일 `isSending` 기준이라 Phase 9 이후 정책인 `requestId` 단위 독립 pending과 완전히 맞지 않는 보완 필요 사항을 발견했다. 해당 보완은 Phase 10의 ViewModel WebSocket 세션/전송 전환 범위에서 처리한다.
|
|
|
|
- [x] **Task 9.4: 제거 endpoint API 정의 삭제**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
|
- 작업:
|
|
- `DmChatApi.sendDmTextMessage()` endpoint 정의를 삭제한다.
|
|
- `DmChatApi.disconnectRealtime()` endpoint 정의를 삭제한다.
|
|
- `SendDmTextMessageRequest`, `SendDmChatMessageResponse` 모델은 REST 텍스트 전송 전용이면 삭제한다.
|
|
- 음성 메시지 multipart REST API가 현재 DM 채팅 화면에 필요하면 별도 `sendDmVoiceMessage()`로 명시하고, 텍스트 전송 경로와 섞지 않는다.
|
|
- 검증:
|
|
- Run: `rg "messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\\(" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
|
- Expected: WebSocket 전환 후 유지가 필요한 과거 검증 로그를 제외하고 main/test 코드의 신규 경로에서 결과 없음.
|
|
- 검증 기록:
|
|
- 2026-06-18: `messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\(` 검색 결과 출력이 없음을 확인했다. 대상 실행으로 `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`가 PASS했고, `./gradlew :app:compileDebugKotlin --max-workers=1` 및 style 정리 후 `./gradlew :app:ktlintCheck --max-workers=1`도 PASS했다. `ktlintCheck`에는 기존 `.editorconfig`의 `disabled_rules` deprecation warning만 남았다.
|
|
- 2026-06-18: Phase 9 재검증으로 `rg "messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\\(" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm` 결과 없음, `git diff --check` PASS를 확인했다. 최초 Gradle 실행은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 실패했으나, 승인된 Gradle 실행에서 `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`, `compileDebugKotlin`, `ktlintCheck`가 PASS했다.
|
|
|
|
### Phase 10: ViewModel WebSocket 세션/수신/전송 전환
|
|
|
|
- [ ] **Task 10.1: OpenRoom 성공 후 JOIN_ROOM 흐름으로 연결 기준 변경**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `OpenRoom` 성공 전에는 WebSocket 연결을 시작하지 않는다.
|
|
- `roomOpenedEventLiveData` 또는 동등한 단발 이벤트는 Activity가 WebSocket 연결 시작을 트리거하는 용도로 유지한다.
|
|
- 기존 SSE `connected` callback 기준 상태 갱신을 제거하고, WebSocket `JOINED` 수신 시점에만 실시간 수신 가능 상태로 판단한다.
|
|
- 같은 `roomId`로 이미 연결 중이면 중복 socket 연결과 중복 `JOIN_ROOM` 전송을 막는다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: OpenRoom 전 미연결, OpenRoom 후 connect + `JOIN_ROOM`, `JOINED` 후 connected 상태, 중복 connect 방지 테스트가 PASS.
|
|
|
|
- [ ] **Task 10.2: MESSAGE 수신 반영으로 교체**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- 기존 SSE `onMessage()` callback 연결을 WebSocket `MESSAGE` event callback으로 교체한다.
|
|
- `MESSAGE` payload의 `DmChatMessageResponse`를 기존 mapper로 UI item에 변환한다.
|
|
- 현재 채팅방 메시지는 `messageId` 기준 중복 제거 후 append/merge한다.
|
|
- 현재 room이 아닌 메시지가 payload로 구분 가능하면 현재 목록에 반영하지 않는다.
|
|
- 잘못된 payload 또는 알 수 없는 type은 앱 crash 없이 무시한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: `MESSAGE` append, 중복 `messageId` 제거, 잘못된 payload 무시 테스트가 PASS.
|
|
|
|
- [ ] **Task 10.3: SEND_TEXT/requestId pending 전송으로 교체**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `sendText(text)`는 trim 후 blank면 종료한다.
|
|
- 전송 시 `requestId`를 생성하고 local pending item에 보존한다.
|
|
- REST `repository.sendTextMessage()` 호출을 제거하고 WebSocket `SEND_TEXT`를 전송한다.
|
|
- `SEND_ACK` 수신 시 `requestId`로 pending item을 찾아 서버 `messageId`, `createdAt`, `senderNickname`, `senderProfileImageUrl` 기준으로 확정한다.
|
|
- `ERROR` 또는 timeout 시 해당 pending item을 `FAILED`로 전환한다.
|
|
- `retry(localId)`는 기존 failed item을 유지하고 새 `requestId`를 발급해 `SEND_TEXT`를 다시 전송한다.
|
|
- 같은 `requestId`의 `SEND_ACK`가 중복 수신되면 첫 번째 확정 결과만 반영한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: blank 무시, pending 추가, `SEND_TEXT` 송신, `SEND_ACK` requestId 매칭, `ERROR` 실패, timeout 실패, retry 새 requestId, 중복 ack 무시 테스트가 PASS.
|
|
|
|
- [ ] **Task 10.4: SEND_ACK보다 MESSAGE가 먼저 도착하는 race 처리**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- pending 텍스트와 동일한 서버 메시지가 `SEND_ACK`보다 먼저 `MESSAGE`로 도착할 수 있는 케이스를 테스트로 고정한다.
|
|
- `MESSAGE` payload에 `requestId`가 포함되면 requestId 기준으로 pending item을 확정한다.
|
|
- `MESSAGE` payload에 `requestId`가 없으면 `messageId` 중복 제거를 우선 적용하고, 이후 `SEND_ACK` 도착 시 같은 `messageId`가 중복되지 않게 병합한다.
|
|
- timeout으로 실패 처리된 뒤 늦은 `SEND_ACK`가 도착하면 같은 local item이 아직 존재하는 경우 정상 메시지로 복구한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: MESSAGE 선도착, ACK 후도착, timeout 후 ACK 복구, 중복 `messageId` 방지 테스트가 PASS.
|
|
|
|
### Phase 11: Lifecycle, reconnect, heartbeat, token 갱신 처리
|
|
|
|
- [ ] **Task 11.1: LEAVE_ROOM 후 socket close로 lifecycle 해제 전환**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- `onStop`/화면 이탈 시 기존 `disconnectRealtime()` REST 호출 대신 `LEAVE_ROOM` 전송 후 socket close를 수행한다.
|
|
- 앱 background 진입과 로그아웃 흐름에서 같은 leave/close API를 호출할 수 있도록 ViewModel method를 분리한다.
|
|
- 이미 leave/close 중이면 중복 `LEAVE_ROOM` 전송과 중복 close를 만들지 않는다.
|
|
- close 실패 또는 네트워크 단절은 화면 종료를 막지 않는다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: lifecycle stop에서 `LEAVE_ROOM` + close, 중복 방지, REST disconnect 미호출 테스트가 PASS.
|
|
|
|
- [ ] **Task 11.2: WebSocket reconnect 정책 구현**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- 네트워크 오류 또는 비정상 close가 발생하면 현재 채팅방 화면에 남아 있는 동안에만 재연결을 예약한다.
|
|
- 재연결 성공 후 `JOIN_ROOM`을 다시 보낸다.
|
|
- `JOINED` 수신 후 필요하면 `GetMessages(cursor = null, limit = 20)`로 최신 누락 메시지를 동기화한다.
|
|
- 화면 이탈/background/logout 이후 예약된 reconnect는 실행하지 않는다.
|
|
- backoff 간격과 최대 재시도 횟수는 PRD Open Questions에 남긴 상태이므로, 구현 전 서버 권장값이 없으면 기존 3초 반복 재시도 정책을 WebSocket에도 우선 적용한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: 화면 내부 오류 재연결, 재연결 후 `JOIN_ROOM`, 최신 메시지 동기화, 화면 밖 reconnect 취소 테스트가 PASS.
|
|
|
|
- [ ] **Task 11.3: PING/PONG heartbeat 구현**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
|
- 작업:
|
|
- `JOINED` 이후 heartbeat timer를 시작한다.
|
|
- 주기적으로 `PING`을 전송하고 `PONG` 수신 시 마지막 heartbeat 시간을 갱신한다.
|
|
- `PONG` timeout이면 연결 상태를 disconnected로 바꾸고 WebSocket을 close한 뒤 화면 내부 조건에서 reconnect를 예약한다.
|
|
- leave/close/onCleared 시 heartbeat timer를 취소한다.
|
|
- 서버 권장값이 없으면 heartbeat 주기와 timeout 값은 상수로 분리하고 구현 계획 검증 기록에 남긴다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1`
|
|
- Expected: `PING` 주기 송신, `PONG` 수신 상태 유지, timeout reconnect, leave 시 timer 취소 테스트가 PASS.
|
|
|
|
- [ ] **Task 11.4: access token refresh 시 WebSocket 재연결**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- 작업:
|
|
- token provider가 이전 handshake token과 다른 token을 반환하면 기존 WebSocket을 close한다.
|
|
- 새 token으로 WebSocket handshake를 다시 수행한다.
|
|
- 재연결 후 현재 `roomId`로 `JOIN_ROOM`을 다시 보낸다.
|
|
- token 갱신 중 화면이 종료되면 reconnect를 진행하지 않는다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
|
- Expected: token 변경 감지, 기존 socket close, 새 Authorization header 연결, `JOIN_ROOM` 재전송, 화면 종료 시 중단 테스트가 PASS.
|
|
|
|
### Phase 12: 푸시 진입과 제거 endpoint 회귀 검증
|
|
|
|
- [ ] **Task 12.1: FCM payload에 chat_type 전달 추가**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt`
|
|
- Test: `app/src/test/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingServiceSourceTest.kt`
|
|
- 작업:
|
|
- `sendNotification()`에서 `messageData["chat_type"]`이 있으면 `Constants.EXTRA_DATA` bundle에 `chat_type`을 보존한다.
|
|
- 기존 `room_id`, `message_id`, `deep_link_value` 전달은 유지한다.
|
|
- deep link URL이 있는 경우에도 `chat_type`/`room_id`가 필요한지 서버 payload 정책을 확인하고, URL 우선 정책을 유지한다면 이 동작을 테스트에 명시한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --max-workers=1`
|
|
- Expected: `chat_type`과 `room_id`가 notification intent extras에 포함되는 source test가 PASS.
|
|
|
|
- [ ] **Task 12.2: USER_CREATOR 푸시를 DM 채팅방으로 라우팅**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
|
|
- Test: `app/src/test/java/kr/co/vividnext/sodalive/main/DeepLinkActivitySourceTest.kt`
|
|
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
|
|
- 작업:
|
|
- `chat_type == "USER_CREATOR"`이고 `room_id`가 Long으로 변환 가능하면 `DmChatRoomActivity.newIntentByRoomId(context, roomId)`로 이동한다.
|
|
- 푸시로 진입한 채팅방도 일반 진입과 동일하게 `OpenRoom` 후 WebSocket 연결/`JOIN_ROOM`을 수행한다.
|
|
- `room_id`가 없거나 잘못된 경우 DM 채팅방을 열지 않고 기존 fallback 흐름을 따른다.
|
|
- 기존 live/content/message deep link 처리는 변경하지 않는다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`
|
|
- Expected: USER_CREATOR push routing, invalid room fallback, 기존 deep link 유지 테스트가 PASS.
|
|
|
|
- [ ] **Task 12.3: 제거 endpoint 호출 회귀 방지 테스트 추가**
|
|
- Files:
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRemovedEndpointSourceTest.kt`
|
|
- 작업:
|
|
- source test로 main DM 채팅 코드에서 아래 문자열이 남아 있지 않은지 확인한다.
|
|
- `/api/v2/user-creator-chat/rooms/{roomId}/events`
|
|
- `/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
|
- `/api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
|
- `Accept: text/event-stream`
|
|
- `EventSource`
|
|
- ViewModel 테스트에서 텍스트 전송이 REST repository method를 호출하지 않고 WebSocket `SEND_TEXT`를 호출함을 검증한다.
|
|
- lifecycle close 테스트에서 REST disconnect method가 호출되지 않고 `LEAVE_ROOM` + close만 호출됨을 검증한다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1`
|
|
- Expected: 제거 endpoint 문자열 없음, 텍스트 전송 WebSocket 사용, leave/close WebSocket 사용 테스트가 PASS.
|
|
|
|
- [ ] **Task 12.4: 음성 메시지 REST 유지 범위 확인**
|
|
- Files:
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
|
- 작업:
|
|
- 현재 DM 채팅 화면에서 음성 메시지 전송 UI가 없으면 API 추가 없이 DTO의 `voiceMessageUrl` 보존만 유지한다.
|
|
- DM 음성 전송 UI가 이미 연결되어 있거나 서버 배포 범위에서 필요하면 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart API만 추가한다.
|
|
- 음성 전송 경로는 WebSocket `SEND_TEXT`/`SEND_ACK` pending 정책과 섞지 않는다.
|
|
- 검증:
|
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1`
|
|
- Expected: 음성 API를 추가한 경우 multipart endpoint만 검증되고, 추가하지 않은 경우 기존 DTO 보존 테스트가 PASS.
|
|
|
|
### Phase 13: WebSocket 전환 최종 검증과 수동 확인
|
|
|
|
- [ ] **Task 13.1: DM 채팅 WebSocket 단위 테스트 실행**
|
|
- Files:
|
|
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/*Test.kt`
|
|
- Run:
|
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`
|
|
- Expected:
|
|
- DM 채팅 WebSocket 전환 관련 단위 테스트가 모두 PASS.
|
|
|
|
- [ ] **Task 13.2: 푸시/딥링크 라우팅 테스트 실행**
|
|
- Files:
|
|
- Check: `app/src/test/java/kr/co/vividnext/sodalive/fcm/*Test.kt`
|
|
- Check: `app/src/test/java/kr/co/vividnext/sodalive/main/*Test.kt`
|
|
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/*Test.kt`
|
|
- Run:
|
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1`
|
|
- Expected:
|
|
- USER_CREATOR push routing과 기존 deep link 회귀 테스트가 PASS.
|
|
|
|
- [ ] **Task 13.3: 빌드/스타일/문자열 회귀 확인**
|
|
- Files:
|
|
- Check: Gradle project
|
|
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
|
- Run:
|
|
- `./gradlew :app:compileDebugKotlin --max-workers=1`
|
|
- `./gradlew :app:ktlintCheck --max-workers=1`
|
|
- `git diff --check`
|
|
- `rg "messages/text|events/disconnect|text/event-stream|EventSource" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
|
- Expected:
|
|
- Kotlin compile, ktlint, whitespace check PASS.
|
|
- 제거 endpoint 문자열은 삭제 검증 테스트나 과거 이력 문서를 제외한 신규 DM main/test 코드에 남지 않는다.
|
|
|
|
- [ ] **Task 13.4: WebSocket 전환 수동 확인**
|
|
- Files:
|
|
- Check: `DmChatRoomActivity`
|
|
- Check: `SodaFirebaseMessagingService`
|
|
- Check: `DeepLinkActivity`
|
|
- Check: `MainV2Activity`
|
|
- 확인 항목:
|
|
- 채팅 탭 DM item 클릭 시 `OpenRoom` 호출 후 WebSocket `/ws/v2/user-creator-chat` 연결과 `JOIN_ROOM` 전송이 발생한다.
|
|
- `JOINED` 수신 전에는 실시간 수신 상태로 판단하지 않는다.
|
|
- 상대방 메시지 `MESSAGE` 수신 시 현재 채팅방 메시지 목록에 append된다.
|
|
- 텍스트 전송 시 REST `/messages/text`가 호출되지 않고 `SEND_TEXT`가 전송된다.
|
|
- `SEND_ACK` 수신 시 pending 메시지가 서버 `messageId`, `createdAt`, 프로필 정보로 확정된다.
|
|
- `ERROR` 또는 timeout 시 pending 메시지가 실패 상태와 재시도 버튼을 표시한다.
|
|
- 화면 이탈, 앱 background, 로그아웃 시 `LEAVE_ROOM` 전송 후 socket close가 발생하고 `/events/disconnect`는 호출되지 않는다.
|
|
- 네트워크 오류 후 같은 채팅방 화면에 남아 있으면 reconnect 후 `JOIN_ROOM`과 누락 메시지 동기화가 수행된다.
|
|
- 화면 밖에서는 reconnect가 예약/실행되지 않는다.
|
|
- heartbeat `PING`/`PONG` timeout 시 연결 상태가 disconnected로 전환된다.
|
|
- USER_CREATOR push 터치 시 `room_id` 기준 DM 채팅방에 진입하고 일반 진입과 동일하게 `OpenRoom` 후 WebSocket join을 수행한다.
|
|
|
|
## 5. 검증 기록
|
|
- 2026-06-10: `docs/20260610_DM_채팅화면/prd.md`를 확인해 DM 채팅방 진입, UI 제거 대상, REST API, SSE 이벤트, pagination, 전송 실패/재시도, lifecycle disconnect 요구사항을 계획에 반영했다.
|
|
- 2026-06-10: `docs/agent-guides/work-plan-docs.md`, `docs/agent-guides/build-test-style.md`, `docs/agent-guides/code-style.md`를 확인해 신규 계획 문서 위치, phase/task 체크박스 형식, 테스트 명령 작성 방식을 확인했다.
|
|
- 2026-06-10: `ChatRoomActivity.kt`, `activity_chat_room.xml`, `ChatRoomApi.kt`, `ChatRoomModels.kt`, `ChatRoomRepository.kt`, `ChatMainFragment.kt`, `AppDI.kt`, `AndroidManifest.xml`을 확인해 기존 채팅 화면 구조, v2 API/Repository 패턴, DI/Manifest 등록 위치, DM item 클릭 연결 지점을 확인했다.
|
|
- 2026-06-10: `rg`로 `EventSource`, `text/event-stream`, `ResponseBody`, `OkHttpClient` 사용 현황을 확인했으며 저장소에 기존 SSE 구현 패턴은 없고 OkHttp 의존성은 이미 존재함을 확인했다. 이에 따라 별도 라이브러리 추가 없이 OkHttp streaming 기반 `DmChatEventClient`를 계획에 반영했다.
|
|
- 2026-06-10: 이번 단계는 계획 문서 생성만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
|
- 2026-06-10: Phase 1 구현 전 `DmChatMapperTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"`를 실행해 `DmChatMessageResponse` 및 `dm.model` 심볼 부재로 실패하는 RED 상태를 확인했다.
|
|
- 2026-06-10: Phase 1 범위로 `DmChatApi.kt`, `DmChatModels.kt`, `DmChatUiModels.kt`, `DmChatMappers.kt`, `DmChatMapperTest.kt`를 추가했다. DTO/API는 PRD 서버 필드를 `@SerializedName`으로 보존하고 `Single<ApiResponse<...>>` 반환 타입을 사용하도록 정의했다.
|
|
- 2026-06-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
- 2026-06-10: `DmChatMappers.kt`와 `DmChatMapperTest.kt`를 보강해 `messageType` 대소문자 무시 `TEXT` 매핑, `TEXT` 외 타입 및 `textMessage == null` 미매핑, `createdAt` 동일 시 `messageId` 오름차순, 중복 `messageId` 선도착 우선 정책을 명시했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"`와 `./gradlew :app:ktlintCheck` PASS를 확인했다.
|
|
- 2026-06-10: Phase 2 구현 전 `DmChatEventParserTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventParserTest"`를 실행해 `DmChatEventParser` 심볼 부재로 실패하는 RED 상태를 확인했다.
|
|
- 2026-06-10: Phase 2 범위로 `DmChatRepository.kt`, `DmChatEventClient.kt`, `DmChatEventParserTest.kt`, `DmChatRepositoryTest.kt`를 추가했다. Repository는 `bearer(token)` helper를 통해 모든 REST API auth header를 생성하고 API 호출만 얇게 위임하도록 구현했다. SSE 클라이언트는 OkHttp `Call.enqueue()`와 streaming `ResponseBody`를 사용하고, `connected`/`message` event frame parser를 분리했다.
|
|
- 2026-06-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventParserTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
- 2026-06-10: Phase 2 리뷰에서 SSE stream read 중 `IOException`이 failure callback으로 전달되지 않는 문제가 발견되어, 취소되지 않은 call의 read 실패만 `listener.onFailure(e)`로 전달하도록 `DmChatEventClient`를 수정했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck` PASS를 재확인했다.
|
|
- 2026-06-10: SSE 클라이언트 보강으로 비정상 HTTP 응답 failure callback 전달, trailing blank line 없는 마지막 frame dispatch, `data:` 뒤 단일 공백 제거 정책, listener `@Volatile` 가시성 보완을 반영했다. 보강 전 `DmChatEventClientTest`에서 HTTP 500 failure 누락과 마지막 frame 누락 RED를 확인했고, 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` PASS를 확인했다.
|
|
- 2026-06-10: Phase 3 구현 전 `DmChatRoomViewModelTest`, `DmChatPaginationStateTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`를 실행해 `DmChatRoomViewModel` 및 `DmChatRoomUiState` 심볼 부재로 실패하는 RED 상태를 확인했다.
|
|
- 2026-06-10: Phase 3 범위로 `DmChatRoomViewModel.kt`, `DmChatUiModels.kt`, `DmChatRoomViewModelTest.kt`, `DmChatPaginationStateTest.kt`를 추가/수정했다. roomId/creatorId 진입, invalid 종료 이벤트, OpenRoom 정렬, pagination guard/prepend/중복 제거, blank 전송 무시, pending 추가, 성공 교체, 실패 상태, retry 성공, SSE 중복 제거, 재연결 후 최신 메시지 동기화를 ViewModel 상태로 구현했다.
|
|
- 2026-06-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"` PASS를 확인했다.
|
|
- 2026-06-10: Phase 3 최종 확인으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
- 2026-06-10: Phase 3 리뷰에서 전송 성공 전 SSE echo가 먼저 도착하면 같은 `messageId`가 중복될 수 있는 문제가 발견되어 `DmChatRoomViewModelTest`에 재현 테스트를 추가했다. 보강 전 해당 테스트는 중복 메시지 assertion으로 RED를 확인했고, 전송 성공 local 교체 후 동일 `messageId`를 한 개로 정리하도록 `DmChatRoomViewModel`을 수정했다. 이후 해당 단일 테스트와 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"` PASS를 확인했다.
|
|
- 2026-06-10: Phase 3 추가 리뷰 보강으로 retry 중 SSE echo가 먼저 도착해도 성공 교체 후 동일 `messageId`가 한 개만 남는 테스트, 과거 메시지 요청 실패 시 `isLoadingOlder=false`로 복구하고 기존 목록을 유지하는 테스트, 재연결 최신 메시지 동기화 실패 시 기존 메시지를 유지하는 테스트를 추가했다. `isSending` 단일 전송 guard와 pagination/reconnect 실패의 silent 유지 정책은 Phase 3 ViewModel 범위의 의도된 제약으로 문서화했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"` PASS를 확인했다.
|
|
|
|
- 2026-06-10: Phase 4 범위로 `activity_dm_chat_room.xml`, `item_dm_chat_my_message.xml`, `item_dm_chat_opponent_message.xml`, `DmChatMessageAdapter.kt`를 추가했다. DM 레이아웃은 기존 채팅방의 배경/딤/header/메시지 목록/input 구조를 따르되 header에는 `iv_back`, `iv_profile`, `tv_name`만 두고 `rv_messages`를 `header_container` 하단에 연결했다. 메시지 Adapter는 `DiffUtil`, stable id(`messageId` 우선, `localId` fallback), 내 메시지 실패 재시도 callback, 상대 프로필 `ic_placeholder_profile` 로딩을 구현했다.
|
|
- 2026-06-10: Phase 4 검증으로 `rg "character_type_badge|ll_can_badge|iv_more|notice_container" app/src/main/res/layout/activity_dm_chat_room.xml` 결과 없음, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:assembleDebug`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다. Phase 4 변경 파일 대상 리뷰어 검토에서도 blocking issue 없음 PASS를 받았다.
|
|
- 2026-06-10: Phase 4 리뷰 개선 권장 사항 반영으로 DM 메시지 item XML의 고정 `layout_constraintWidth_max`를 제거해 말풍선 폭 제어를 Adapter 비율 기준으로 통일했다. 내 메시지는 기존 사용자 메시지 관례처럼 65%, 상대 메시지는 기존 AI/상대 메시지 관례와 `guideline_90`에 맞춰 90%를 적용했다. 또한 `DmChatMessageAdapter`의 local/fallback stable id를 64-bit 문자열 해시 기반 음수 namespace로 분리해 서버 `messageId`와의 충돌 가능성을 낮췄다.
|
|
- 2026-06-10: Phase 4 리뷰 개선 반영 후 `rg "layout_constraintWidth_max" app/src/main/res/layout/item_dm_chat_my_message.xml app/src/main/res/layout/item_dm_chat_opponent_message.xml` 결과 없음, `./gradlew :app:ktlintCheck`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"` PASS를 확인했다. 최초 병렬 Gradle 실행에서는 `kspCaches/debug` 증분 캐시 동시 접근으로 `:app:kspDebugKotlin`이 실패했으나, 동일 명령을 순차 재실행해 PASS를 확인했다.
|
|
- 2026-06-10: Phase 4 재리뷰 후속 보강으로 남은 개선 권장 사항을 반영했다. (4) 재시도 아이콘을 시스템 리소스 `@android:drawable/ic_popup_sync`에서 프로젝트 전용 vector `ic_dm_retry`로 교체했다. (5) 내/상대 말풍선 폭 기준 불일치(65% vs 90%)를 단일 상수 `MESSAGE_MAX_WIDTH_RATIO=0.68f`로 통일하고, `item_dm_chat_opponent_message.xml`의 `guideline_90` 의존을 제거해 폭 제어를 Adapter 비율 단일 소스로 일원화했다. 권장 1(폭 이중 제어)·2(stableId namespace)는 직전 보강에서 이미 반영된 상태를 확인했다.
|
|
- 2026-06-10: 위 보강 검증으로 `./gradlew :app:ktlintCheck`(ktlintMainSourceSetCheck) PASS를 확인했다. 단, 본 작업 환경에는 JDK 17이 없고 Android Studio JBR 21만 존재해 `jvmToolchain(17)`을 요구하는 `:app:assembleDebug`/`testDebugUnitTest`는 toolchain 미탐지로 실행하지 못했다. 변경은 XML/소량 Kotlin 수정으로 surgical하며 JDK 17 환경에서 빌드/테스트 재확인이 필요하다.
|
|
- 2026-06-10: Phase 5 구현 전 `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`, `ChatMainFragmentLayoutTest`를 추가/수정하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`를 실행해 `DmChatRealtimeClient`, `connectRealtime()`, `disconnectRealtime()`, `DmChatRoomActivity`/DM routing 부재로 RED 상태를 확인했다.
|
|
- 2026-06-10: Phase 5 범위로 `DmChatRoomActivity.kt`를 추가하고 Activity intent helper, ViewBinding, header, RecyclerView, input/IME send, 상단 pagination, prepend scroll 보정, 하단 근처 auto-scroll을 연결했다. `DmChatRoomViewModel`에는 SSE connect/disconnect lifecycle, connected callback 최신 동기화, message callback 병합, duplicate connect/disconnect guard, disconnect 실패 silent 처리 상태를 추가했다. `DmChatRepository`에는 `DmChatRealtimeClient` seam을 추가하고 `DmChatEventClient`가 이를 구현하도록 정리했다. `ChatMainFragment`는 AI item은 기존 `ChatRoomActivity`, DM item은 `DmChatRoomActivity.newIntentByRoomId()`로 이동하도록 분기했다.
|
|
- 2026-06-10: Phase 5 검증으로 targeted 테스트 3종, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
- 2026-06-10: Phase 5 리뷰 게이트에서 SSE listener callback이 OkHttp background thread에서 호출되어 `LiveData.setValue()` crash 가능성이 있다는 blocking issue가 발견됐다. `DmChatRoomViewModelTest`에 main thread scheduler 사용을 고정하는 RED 테스트를 추가해 실패를 확인한 뒤, `connectRealtime()`의 `onConnected`/`onMessage`/`onFailure` 상태 갱신을 `AndroidSchedulers.mainThread().scheduleDirect`로 marshal하도록 수정했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 재확인했다.
|
|
- 2026-06-11: Phase 5 코드리뷰 권장/관찰 항목을 계획 문서에 후속 Task로 반영했다. Phase 5에는 `bindContent`의 반복 `connectRealtime()` 호출 정리, SSE 자동 재연결 미구현 정책 인지, disconnect 중 빠른 재진입 확인을 `Task 5.4`로 추가했다. Phase 6에는 SSE 전용 read timeout 제거를 `Task 6.4`, realtime callback scheduling `Disposable` 누적 방지를 `Task 6.5`로 추가했다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
|
|
- 2026-06-11: `prd.md`의 SSE Realtime Events 요구사항과 성공 기준을 재확인한 결과, 네트워크 오류 후 SSE 자동 재연결은 PRD 범위에 포함되는 것으로 판단했다. 이에 따라 `Task 5.4`의 “자동 재연결 미구현” 문구를 정정하고, foreground/활성 채팅방 상태에서 서버 `reconnectTime=3000`ms 기준 재연결 예약, 재연결 성공 후 `GetMessages` 누락 메시지 보정, 화면 이탈/background 전환 시 예약 재연결 취소를 후속 작업으로 명시했다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
|
|
- 2026-06-11: Task 5.4 리뷰 게이트 후속 보강으로 `disconnectRealtime()`의 local realtime 정리 순서를 `isDisconnecting` API 중복 guard보다 앞에 두어 disconnect API 진행 중 다시 background로 가는 경우에도 새 SSE 연결과 예약 재연결이 cancel되도록 수정했다. 또한 `DmChatEventClient`가 취소되지 않은 SSE stream EOF 종료를 `SSE stream closed` failure callback으로 전달하도록 보강해 조용한 stream 종료도 ViewModel의 3초 재연결 경로로 들어가게 했다. 기존 403번째 stale 완료 기록은 제거 상태를 유지했다. 회귀 테스트로 `disconnect API 진행 중 다시 background로 가면 새 SSE 연결도 cancel하고 API 중복 호출은 하지 않는다`, `취소되지 않은 SSE stream이 EOF로 종료되면 failure callback으로 전달된다`를 추가했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 확인했다.
|
|
- 2026-06-11: Phase 5 코드리뷰 권장 변경사항 A-D를 각각 후속 Task로 추가했다. `Task 5.5`는 자동 재연결 실행 스레드 race 제거, `Task 5.6`은 disconnect와 예약 재연결 경합 방지, `Task 5.7`은 SSE 재연결 backoff 또는 시도 제한 검토, `Task 5.8`은 `roomOpenedEventLiveData` 스티키 재전달 방지를 다룬다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
|
|
- 2026-06-11: Phase 5.5~5.8 범위로 예약 재연결 실행 시 `connectRealtime()`를 scheduler thread에서 직접 호출하지 않고 main callback 경로로 전달하도록 수정했다. disconnect 이후 예약 재연결이 실행되어도 `shouldReconnectRealtime` 재확인으로 새 SSE 연결이 남지 않도록 했고, `roomOpenedEventLiveData`는 `DmChatEvent<Boolean>` 소비형 이벤트로 바꿔 observer 재등록만으로 realtime connect가 반복 트리거되지 않도록 했다. SSE 재연결 정책은 PRD의 서버 `reconnectTime=3000`ms 및 foreground 한정 조건을 우선해 backoff/최대 횟수 제한을 추가하지 않고 3초 반복 재시도 유지로 테스트 고정했다.
|
|
- 2026-06-11: Phase 5.5~5.8 검증으로 RED 단계에서 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`가 `consume` API 부재로 실패함을 확인했고, 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
|
|
- 2026-06-11: Phase 6.1~6.5 범위로 `AppDI.kt`에 `DmChatApi`, `DmChatRealtimeClient`/`DmChatEventClient`, `DmChatRepository`, `DmChatRoomViewModel` DI 등록을 추가하고, `AndroidManifest.xml`에 `.v2.main.chat.dm.DmChatRoomActivity`를 `stateAlwaysHidden|adjustResize`로 등록했다. `docs/agent-guides/build-test-style.md`에는 DM 채팅 단위 테스트 wildcard 실행 예시를 추가했다.
|
|
- 2026-06-11: Phase 6.4 RED 단계에서 `DmChatEventClientTest`에 공유 `OkHttpClient`의 60초 read timeout이 SSE 전용 client에서 0으로 제거되어야 한다는 테스트를 추가했고, 기존 구현에서 `assertEquals(0, sseClient.readTimeoutMillis)` 실패를 확인했다. 이후 `DmChatEventClient`가 전달받은 client에서 `readTimeout(0, TimeUnit.MILLISECONDS)`를 적용한 전용 client를 생성하도록 수정했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"` PASS를 확인했다.
|
|
- 2026-06-11: Phase 6.5 RED 단계에서 `DmChatRoomViewModelTest`에 반복 realtime message callback 후 `compositeDisposable.size()`가 증가하지 않아야 한다는 테스트를 추가했고, 기존 `AndroidSchedulers.mainThread().scheduleDirect` disposable 누적으로 실패를 확인했다. 이후 realtime callback 전달을 main thread에서는 즉시 실행, background thread에서는 `Handler(Looper.getMainLooper()).post { ... }`로 전달하도록 수정해 완료된 callback `Disposable` 누적을 제거했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"` PASS를 확인했다.
|
|
- 2026-06-11: Phase 7.1~7.3 자동 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck`를 순차 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다. Phase 7.4 수동 화면 확인은 현재 연결된 기기/에뮬레이터 실기 실행 증거가 없어 완료 처리하지 않았다.
|
|
- 2026-06-11: Phase 6, 7 코드리뷰 개선사항을 후속 Task로 추가했다. `Task 6.6`은 `DmChatRoomViewModel.onCleared()`에서 main handler callback, disposable, realtime client 정리를 보장하는 작업이고, `Task 6.7`은 `DmChatEventClientTest`의 `getDeclaredField("okHttpClient")` reflection 의존을 동작 기반 검증으로 대체하거나 유지 사유를 명확히 남기는 작업이다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
|
|
- 2026-06-11: Phase 6.6 RED 단계에서 `DmChatRoomViewModelTest`에 `onCleared()` 정리 테스트를 추가했고, 기존 구현에서 `onCleared()` 오버라이드 및 `mainHandler.removeCallbacksAndMessages(null)` 부재로 실패를 확인했다. 이후 `DmChatRoomViewModel.onCleared()`에서 예약된 main handler callback 제거, `reconnectDisposable` dispose, realtime client cancel을 수행하고 `super.onCleared()`로 기존 `compositeDisposable.dispose()` 정리를 유지하도록 수정했다.
|
|
- 2026-06-11: Phase 6.7에서 `DmChatEventClientTest`의 `getDeclaredField("okHttpClient")` reflection 검증을 제거하고, OkHttp interceptor의 `chain.readTimeoutMillis()`를 실제 SSE 요청 경로에서 관찰하는 동작 기반 검증으로 대체했다. 비동기 요청 실행을 기다리기 위해 `CountDownLatch`를 사용했고, 공유 client의 60초 read timeout은 유지되며 SSE 요청 경로에서는 0ms read timeout이 적용됨을 확인했다.
|
|
- 2026-06-11: Phase 6.6~6.7 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. 최초 병렬 Gradle 실행에서는 Kotlin incremental cache 동시 접근으로 timeout/daemon fallback이 발생해 순차 실행으로 재검증했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
|
- 2026-06-11: Phase 6.6 리뷰 후속으로 `onCleared()` 테스트를 source 문자열 검증에서 런타임 동작 검증으로 보강했다. 예약된 realtime 재연결이 `onCleared()` 후 실행되지 않는지, background thread에서 예약된 `mainHandler.post { action() }` callback이 `onCleared()` 후 상태를 갱신하지 않는지 확인하도록 수정했다. Phase 6.7 테스트에는 `requestLatch.await(2, TimeUnit.SECONDS)` 반환값 assertion을 추가해 timeout 검증 의도를 명확히 했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1` PASS를 확인했다.
|
|
- 2026-06-18: `docs/20260610_DM_채팅화면/prd.md`의 WebSocket 전환 요구사항을 기준으로 `plan-task.md`를 보강했다. 기존 Phase 1~8의 SSE 구현 이력은 보존하고, Phase 9~13에 WebSocket 모델/클라이언트, Repository/DI 전환, `JOIN_ROOM`/`JOINED`, `MESSAGE`, `SEND_TEXT`/`SEND_ACK`, `LEAVE_ROOM`/close, reconnect/heartbeat/token refresh, USER_CREATOR push routing, 제거 endpoint 회귀 검증, 최종 수동 확인 작업을 추가했다.
|
|
- 2026-06-18: 현재 코드 기준으로 `DmChatApi`의 `/messages/text`, `/events/disconnect`, `DmChatEventClient`, `DmChatRoomViewModel.sendText()` REST 전송, `disconnectRealtime()` SSE 해제 경로가 남아 있음을 확인했고, 이를 Phase 9~13에서 제거/교체할 대상으로 문서화했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|