Files
sodalive-android/docs/20260610_DM_채팅화면/plan-task.md

334 lines
24 KiB
Markdown

# DM 채팅화면 구현 계획/TASK
## 1. 목표
`docs/20260610_DM_채팅화면/prd.md`를 기준으로 신규 DM 채팅방 상세 화면을 v2 패키지 하위에 구현한다. 기존 AI `ChatRoomActivity`는 직접 수정하지 않고, DM 전용 Activity, ViewModel, Repository, DTO, SSE 클라이언트, 메시지 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>` 패턴을 사용한다.
- SSE는 현재 저장소에 재사용 패턴이 없으므로 별도 라이브러리 추가 없이 기존 `OkHttpClient``newCall()`과 streaming `ResponseBody`를 사용하는 `DmChatEventClient`를 추가한다.
- SSE 연결/해제는 Activity foreground 범위에서 처리한다.
- `onStart`: `OpenRoom` 완료 후 연결 가능 상태면 SSE 연결을 시작한다.
- `onStop`: SSE call을 cancel하고 `DisconnectRealtime` API를 비동기로 호출한다.
- `onDestroy`: listener 참조와 disposable을 정리한다.
- `Last-Event-ID` replay는 기대하지 않고, SSE 재연결 후 `GetMessages`로 최신 누락 가능 메시지를 동기화한다.
- `VOICE` 메시지는 DTO에 보존하되 이번 UI 목록에는 표시하지 않는다.
- 전송은 낙관적 UI를 적용한다.
- 전송 직후 local pending 메시지를 추가한다.
- 성공 시 서버 응답 메시지로 교체한다.
- 실패 시 실패 상태와 재시도 버튼을 표시한다.
## 3. 파일 구조
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
- DM 채팅방 화면, intent 진입, RecyclerView/input/header/lifecycle/SSE 연결 제어를 담당한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
- 방 생성/열기, pagination, 메시지 전송, SSE 이벤트 반영, disconnect 상태를 관리한다.
- 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`
- API 호출 래핑, token 전달, SSE 클라이언트 위임을 담당한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
- OkHttp 기반 SSE 연결, `connected`/`message` 이벤트 파싱, cancel 처리를 담당한다.
- 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`, `DmChatEventClient`, `DmChatRoomViewModel` DI를 추가한다.
- Modify: `app/src/main/AndroidManifest.xml`
- `DmChatRoomActivity`를 등록한다.
- 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 입력을 무시하고, 전송 중 중복 요청을 막는다.
- 전송 실패 메시지는 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
- 화면 stop/destroy 흐름에서 SSE cancel과 disconnect 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 상태와 단위 테스트 추가
- [ ] **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.
- [ ] **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.
- [ ] **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 구현
- [ ] **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: 결과 없음.
- [ ] **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 구현과 화면 연결
- [ ] **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 관련 로직을 가져오지 않는다.
- [ ] **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 호출 여부를 검증한다.
- [ ] **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 클릭 동작은 기존과 동일하게 유지한다.
### Phase 6: DI, Manifest, 문서 갱신
- [ ] **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 등록 순서를 불필요하게 재정렬하지 않는다.
- [ ] **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 패턴을 따른다.
- [ ] **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.*"`
- 검증:
- 문서 변경은 신규 테스트 명령 예시 추가로만 제한한다.
### Phase 7: 최종 검증과 기록
- [ ] **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.
- [ ] **Task 7.2: 앱 빌드 확인**
- Files:
- Check: Gradle project
- Run:
- `./gradlew :app:assembleDebug`
- Expected:
- Debug APK 빌드 PASS.
- [ ] **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 입력은 전송되지 않는다.
- 텍스트 전송 실패 시 실패 상태와 재시도 버튼이 표시된다.
- 화면 이탈 또는 앱 background 전환 시 disconnect API가 호출된다.
- SSE 연결 실패가 앱 crash로 이어지지 않는다.
## 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를 확인했다.