diff --git a/docs/20260610_DM_채팅화면/plan-task.md b/docs/20260610_DM_채팅화면/plan-task.md new file mode 100644 index 00000000..864a0726 --- /dev/null +++ b/docs/20260610_DM_채팅화면/plan-task.md @@ -0,0 +1,328 @@ +# 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` 패턴을 사용한다. +- 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>`를 사용한다. + +- [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 클라이언트 추가 + +- [ ] **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를 사용한다. + +- [ ] **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>` 반환 타입을 사용하도록 정의했다. +- 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를 확인했다. diff --git a/docs/20260610_DM_채팅화면/prd.md b/docs/20260610_DM_채팅화면/prd.md new file mode 100644 index 00000000..ed6fffe5 --- /dev/null +++ b/docs/20260610_DM_채팅화면/prd.md @@ -0,0 +1,367 @@ +# PRD: DM 채팅화면 + +## 1. Overview +`ChatRoomActivity`와 유사한 DM 채팅방 상세 화면을 제공하고, 사용자와 크리에이터 간 DM 메시지를 `user-creator-chat` API와 SSE 기반 실시간 이벤트로 송수신한다. + +--- + +## 2. Problem +- 기존 `ChatRoomActivity`는 AI 캐릭터 채팅방 기준 화면으로, 캐릭터 타입 배지, CAN 배지, 더보기, 안내 메시지, 쿼터/유료 메시지 흐름이 포함되어 있다. +- 채팅 탭의 DM item 클릭 시 이동할 DM 상세 화면이 아직 별도 범위로 구현되어 있지 않다. +- DM 채팅은 AI 채팅과 다르게 크리에이터와 사용자 간 메시지 송수신, SSE 실시간 이벤트 연결/해제, 커서 기반 과거 메시지 조회가 핵심이다. +- 화면 이탈 또는 앱 백그라운드 전환 시 실시간 연결 해제 API를 항상 호출해야 하므로 생명주기 요구사항을 명확히 문서화해야 한다. +- REST pagination과 SSE 실시간 수신 결과가 겹칠 수 있으므로 메시지 병합/중복 제거 기준이 필요하다. + +--- + +## 3. Goals +- 기존 `ChatRoomActivity`의 채팅방 상세 UI 구조를 최대한 재사용하되, DM에 맞지 않는 UI를 제거한 화면을 정의한다. +- `CreateOrGetRoom`, `OpenRoom`, `ConnectEvents`, `GetMessages`, `SendTextMessage`, `DisconnectRealtime` API 계약을 Android 프로젝트 네이밍에 맞게 정리한다. +- DM 채팅방 진입 시 방 생성/조회 후 생성된 `roomId`로 채팅방을 열고 초기 메시지를 표시한다. +- 사용자가 상단으로 스크롤하면 과거 메시지를 커서 기반으로 추가 조회한다. +- 텍스트 메시지 전송 후 서버 응답 메시지를 화면에 반영한다. +- 채팅방 화면 진입/이탈, 앱 foreground/background 전환에 따른 SSE 연결/해제 정책을 정의한다. +- SSE 이벤트 이름/응답 payload, 재연결 가이드, UI thread 비차단, 최신 메시지 동기화 같은 실시간 연결 운영 기준을 정의한다. + +--- + +## 4. Non-Goals +- AI 캐릭터 채팅방의 쿼터 구매, 광고 보상, 유료 메시지 구매, 채팅 리셋 기능은 DM 채팅화면에 포함하지 않는다. +- `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`는 DM 화면에 표시하지 않는다. +- 음성 메시지 전송/재생 UI는 이번 범위에서 구현하지 않는다. 단, 서버 DTO의 `voiceMessageUrl` 필드는 모델에 보존한다. +- 메시지 삭제, 신고, 차단, 알림 설정, 읽음 처리, unread count 실시간 갱신은 이번 범위에 포함하지 않는다. +- 백엔드 API 스키마와 필드명을 Android에서 임의 변경하지 않는다. Kotlin class 이름만 프로젝트 가이드에 맞게 조정한다. +- 기존 AI `ChatRoomActivity` 동작을 변경하거나 리팩터링하지 않는다. +- 음성 메시지 UI가 아직 정해지지 않았으므로 `messageType=VOICE` 메시지는 DTO에는 보존하되 이번 구현 대상 UI에서 제외한다. + +--- + +## 5. Target Users +- 채팅 탭의 DM 채팅방에서 크리에이터와 1:1 메시지를 주고받으려는 앱 사용자. +- `kr.co.vividnext.sodalive.v2.main.chat` 및 신규 DM 채팅 화면을 구현/유지보수하는 Android 개발자. + +--- + +## 6. User Stories +- 사용자는 크리에이터 프로필 또는 채팅 탭에서 DM 채팅방을 열고 싶다. +- 사용자는 DM 채팅방에 들어왔을 때 상대 프로필, 상대 이름, 최근 메시지를 바로 보고 싶다. +- 사용자는 텍스트를 입력해 크리에이터에게 메시지를 보낼 수 있어야 한다. +- 사용자는 채팅 목록 상단으로 스크롤해 이전 대화를 이어서 확인하고 싶다. +- 사용자는 화면을 벗어나거나 앱이 백그라운드로 전환될 때 실시간 연결이 안전하게 종료되기를 기대한다. + +--- + +## 7. Core Features + +### DM Chat Room Entry +DM 채팅방을 생성하거나 기존 방을 조회한 뒤 상세 화면을 연다. + +#### Requirements +- 크리에이터와의 DM 시작 진입점에서는 `creatorId`를 전달받아 `POST /api/v2/user-creator-chat/rooms/create`를 호출한다. +- API 응답의 `roomId`를 기준으로 DM 채팅방 상세 화면을 연다. +- 채팅 탭 DM item처럼 이미 `roomId`를 알고 있는 진입점은 방 생성/조회 API를 생략하고 바로 OpenRoom 흐름으로 진입할 수 있다. +- `roomId <= 0`인 상태에서는 채팅방을 열지 않고 기존 Activity 종료/오류 처리 정책을 따른다. + +#### API Contract +- Operation: `CreateOrGetRoom` +- Method: `POST` +- Path: `/api/v2/user-creator-chat/rooms/create` +- Request: `CreateUserCreatorChatRoomRequest` +- Response: `CreateUserCreatorChatRoomResponse` + +```kotlin +data class CreateUserCreatorChatRoomRequest( + val creatorId: Long +) + +data class CreateUserCreatorChatRoomResponse( + val roomId: Long +) +``` + +#### Naming Requirements +- Android DTO 이름은 기능 도메인과 현재 프로젝트 가이드를 고려해 `CreateDmChatRoomRequest`, `CreateDmChatRoomResponse` 또는 동등하게 명확한 이름으로 변경한다. +- 파일/패키지는 신규 v2 화면 원칙에 따라 `kr.co.vividnext.sodalive.v2` 하위에 둔다. + +### DM Chat Room UI +기존 `activity_chat_room.xml`과 유사한 전체 구조를 유지하되 DM에 맞지 않는 요소를 제거한다. + +#### Requirements +- 전체 화면은 기존 채팅방처럼 배경 이미지, dim, header, message RecyclerView, input 영역으로 구성한다. +- header에는 뒤로가기, 상대 프로필 이미지, 상대 닉네임을 표시한다. +- 아래 UI는 표시하지 않는다. + - `character_type_badge` + - `ll_can_badge` + - 더보기 버튼 `iv_more` + - 안내 메시지 영역 `notice_container` +- `rv_messages`의 top constraint는 `notice_container`가 아닌 `header_container` 하단을 기준으로 조정한다. +- 입력 영역은 기존 `et_message`, `iv_send`와 유사하게 텍스트 입력 및 전송 버튼을 제공한다. +- 전송 버튼은 입력값이 blank이면 비활성화하고, 입력값이 있으면 활성화한다. + +#### Edge Cases +- 상대 프로필 이미지 URL이 비어 있거나 로딩 실패하면 기존 placeholder 정책을 따른다. +- 상대 닉네임이 비어 있으면 빈 문자열 그대로 표시하고 임의 대체 문구를 추가하지 않는다. +- 키보드 표시, IME send, 화면 하단 padding/inset은 기존 `ChatRoomActivity`의 사용자 경험을 우선 참고한다. + +### Open Room And Initial Messages +생성되었거나 기존에 존재하는 DM 채팅방을 열고 최신 메시지를 표시한다. + +#### Requirements +- 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`을 호출한다. +- `limit` query parameter 기본값은 `20`으로 설정한다. +- 응답의 `messages`를 오래된 메시지부터 최신 메시지 순으로 정렬해 표시한다. +- `hasMore`, `nextCursor`를 저장해 과거 메시지 조회 상태로 사용한다. +- 응답의 `opponentNickname`, `opponentProfileImageUrl`을 header 상대 정보로 사용한다. +- `mine=true`인 메시지는 사용자 발신 말풍선, `mine=false`인 메시지는 상대 발신 말풍선으로 표시한다. +- `senderNickname`, `senderProfileImageUrl`은 상대 메시지 item 표시와 메시지별 발신자 정보가 필요한 경우에 활용한다. + +#### API Contract +- Operation: `OpenRoom` +- Method: `GET` +- Path: `/api/v2/user-creator-chat/rooms/{roomId}/open` +- Query: `limit=20` +- Response: `UserCreatorChatRoomOpenResponse` + +```kotlin +data class UserCreatorChatRoomOpenResponse( + val roomId: Long, + val opponentNickname: String, + val opponentProfileImageUrl: String, + val messages: List, + val hasMore: Boolean, + val nextCursor: Long? +) +``` + +### SSE Realtime Events +채팅방이 열려 있는 동안 서버 이벤트를 연결해 새 메시지를 실시간으로 반영한다. + +#### Requirements +- OpenRoom 성공 후 `GET /api/v2/user-creator-chat/rooms/{roomId}/events`를 호출해 SSE 연결을 시작한다. +- SSE 연결은 화면이 foreground에 있고 채팅방이 활성 상태일 때만 유지한다. +- 화면을 벗어나거나 앱이 background로 전환되면 `DisconnectRealtime` API를 항상 호출한다. +- 재진입 또는 foreground 복귀 시 현재 `roomId`로 다시 SSE 연결을 시도한다. +- 재연결 성공 후 필요한 경우 GetMessages API로 누락 가능성이 있는 메시지를 동기화한 뒤 SSE 연결 상태를 갱신한다. +- SSE로 수신한 메시지는 기존 목록에 중복 추가하지 않는다. 중복 판단 기준은 `messageId`다. +- SSE 연결 객체는 더 이상 사용하지 않을 때 cancel/close 처리하고 listener 참조를 해제한다. +- 서버는 SSE `reconnectTime`을 `3000`ms로 내려주므로 클라이언트 재연결 기본 간격은 3초를 따른다. +- 서버가 각 이벤트에 `id`를 부여하더라도 현재 백엔드는 재연결 시 `Last-Event-ID`를 해석해 유실 이벤트를 replay하지 않는다. +- 네트워크 오류로 인한 재연결은 서버의 3초 가이드를 따르되, 구현 라이브러리가 추가 backoff/jitter를 제공하는 경우 즉시 무한 재시도가 발생하지 않도록 적용한다. +- disconnect/cancel 처리는 UI thread를 블로킹하지 않아야 한다. +- 현재 저장소에는 SSE/EventSource 구현 패턴이 없으므로 구현 계획에서 OkHttp 기반 EventSource, Retrofit streaming, 별도 라이브러리 사용 여부를 확정한다. + +#### Event Payloads +- `connected` 이벤트 + - 이벤트 이름: `connected` + - 데이터 형식: `"connected"` 단순 문자열 + - 용도: SSE 연결 성공 시 최초 1회 발송되는 handshake 이벤트다. +- `message` 이벤트 + - 이벤트 이름: `message` + - 데이터 형식: `UserCreatorChatMessageItemDto` JSON 객체 + - 용도: 채팅방에 새로운 메시지가 수신되었을 때 실시간으로 전송된다. + - 주요 필드: `messageId`, `messageType`, `mine`, `createdAt`, `textMessage`, `voiceMessageUrl`, `senderId`, `senderNickname`, `senderProfileImageUrl` + +#### API Contract +- Operation: `ConnectEvents` +- Method: `GET` +- Path: `/api/v2/user-creator-chat/rooms/{roomId}/events` +- Response: `text/event-stream` +- Events: `connected`, `message` +- Event id: 서버가 이벤트 `id`를 내려주지만 현재 서버는 재연결 시 `Last-Event-ID`를 replay 기준으로 처리하지 않는다. + +#### Edge Cases +- 연결 실패 시 앱이 crash 되지 않아야 하며, 기존 네트워크 오류 처리 정책에 맞춰 사용자에게 최소한으로 안내한다. +- SSE 종료/실패 콜백 수신 시 연결 상태를 disconnected로 갱신한다. +- 이미 연결 중이면 중복 연결을 만들지 않는다. +- 연결 해제 API 호출 중 화면 종료가 진행되어도 종료 흐름을 막지 않는다. +- `connected` 이벤트는 UI 메시지 목록에 추가하지 않고 연결 상태 갱신에만 사용한다. +- `message` 이벤트 수신 시 `UserCreatorChatMessageItemDto`로 파싱하고, `messageType=TEXT`인 메시지만 이번 UI 대상으로 표시한다. +- 재연결 후 `Last-Event-ID` 기반 자동 replay를 기대하지 않고, 필요한 경우 GetMessages API로 누락 메시지를 보정한다. + +### Load Older Messages +사용자가 메시지 목록 상단으로 스크롤하면 과거 메시지를 추가 조회한다. + +#### Requirements +- `hasMore=true`이고 `isLoading=false`인 상태에서 상단에 도달하면 과거 메시지 API를 호출한다. +- `cursor`는 OpenRoom 또는 직전 GetMessages 응답의 `nextCursor`를 사용한다. +- `cursor`는 현재 화면에 로드된 가장 오래된 메시지보다 이전 페이지를 요청하기 위한 keyset cursor로 취급한다. +- `limit` query parameter 기본값은 `20`으로 설정한다. +- 응답 메시지는 기존 목록 상단에 prepend하고, 스크롤 위치를 유지한다. +- 중복 메시지는 `messageId` 기준으로 제거한다. + +#### API Contract +- Operation: `GetMessages` +- Method: `GET` +- Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages` +- Query: `cursor: Long?`, `limit=20` +- Response: `UserCreatorChatMessagesPageResponse` + +```kotlin +data class UserCreatorChatMessagesPageResponse( + val messages: List, + val hasMore: Boolean, + val nextCursor: Long? +) +``` + +### Send Text Message +사용자가 입력한 텍스트 메시지를 서버로 전송하고 결과를 UI에 반영한다. + +#### Requirements +- 전송 버튼 또는 IME send 액션 시 trim된 입력값이 blank이면 전송하지 않는다. +- 전송 요청이 진행 중인 동안 같은 입력에 대한 연타/중복 전송을 방지한다. +- 텍스트 메시지는 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`로 전송한다. +- 요청 body는 `textMessage`를 사용한다. +- 성공 응답의 `message`를 화면에 반영한다. +- `deliveredRealtime`, `pushSent` 값은 응답 모델에 보존하되, 이번 PRD에서는 별도 UI를 추가하지 않는다. +- 전송 시 낙관적 UI를 적용해 사용자 메시지를 즉시 목록에 추가하고 전송 중 상태로 표시한다. +- 전송 실패 시 해당 메시지를 실패 상태로 전환하고 재시도 버튼을 표시한다. +- 재시도 버튼을 누르면 같은 텍스트 메시지를 다시 전송하고, 성공 시 실패 상태를 정상 메시지 상태로 갱신한다. +- 사용자가 과거 메시지를 보고 있는 상태에서 메시지를 전송하면 최신 메시지 위치로 이동하거나, 최신 페이지 동기화 후 전송 메시지를 반영하는 방식 중 하나를 구현 계획에서 확정한다. + +#### API Contract +- Operation: `SendTextMessage` +- Method: `POST` +- Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages/text` +- Request: `SendUserCreatorTextMessageRequest` +- Response: `SendUserCreatorChatMessageResponse` + +```kotlin +data class SendUserCreatorTextMessageRequest( + val textMessage: String +) + +data class SendUserCreatorChatMessageResponse( + val message: UserCreatorChatMessageItemDto, + val deliveredRealtime: Boolean, + val pushSent: Boolean +) +``` + +### Disconnect Realtime +채팅방 화면 종료 또는 앱 background 전환 시 실시간 연결을 서버에 명시적으로 해제한다. + +#### Requirements +- 화면을 벗어날 때 항상 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출한다. +- 앱이 background로 전환될 때도 같은 API를 호출한다. +- 이미 disconnect 호출 중이면 중복 요청을 만들지 않는다. +- disconnect 성공 여부와 관계없이 화면 종료 자체는 막지 않는다. +- 화면 이탈 시 disconnect는 실시간 연결 해제 범위로 한정하고, 로그아웃처럼 로컬 캐시 삭제가 필요한 흐름과 구분한다. + +#### API Contract +- Operation: `DisconnectRealtime` +- Method: `POST` +- Path: `/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` +- Response: `ApiResponse` + +### Message DTO +서버 메시지 DTO는 텍스트/음성 필드를 모두 보존하되, 이번 화면의 구현 대상은 텍스트 메시지 UI와 텍스트 메시지 전송만으로 한정한다. + +#### Response Model +```kotlin +data class UserCreatorChatMessageItemDto( + val messageId: Long, + val messageType: String, + val mine: Boolean, + val createdAt: Long, + val textMessage: String?, + val voiceMessageUrl: String?, + val senderId: Long, + val senderNickname: String, + val senderProfileImageUrl: String +) +``` + +#### Naming Requirements +- Android DTO 이름은 `DmChatMessageResponse`, `DmChatRoomOpenResponse`, `DmChatMessagesPageResponse`, `SendDmTextMessageRequest`, `SendDmChatMessageResponse` 등 기능 중심 이름으로 조정한다. +- `messageType`은 서버 문자열을 그대로 보존한다. +- 가능한 `messageType` 값은 `TEXT`, `VOICE`다. +- `TEXT`는 텍스트 메시지이며 `textMessage`를 사용해 말풍선 UI로 표시한다. +- `VOICE`는 음성 메시지이며 `voiceMessageUrl`을 보존한다. 음성 메시지 UI가 아직 정해지지 않았으므로 이번 구현에서는 별도 재생/전송 UI를 만들지 않는다. +- `createdAt`은 epoch millis로 보고 기존 시간 표시 유틸 또는 신규 formatter에서 변환한다. + +--- + +## 8. UX / UI Expectations +- 기존 AI 채팅방과 동일한 채팅 화면 감각을 유지하되 DM에서 불필요한 캐릭터/쿼터 요소는 제거한다. +- header는 뒤로가기, 상대 프로필, 상대 닉네임만으로 간결하게 구성한다. +- 메시지 영역은 header 바로 아래부터 시작한다. +- 내 메시지와 상대 메시지의 시각적 구분은 기존 `ChatMessageAdapter`/item 스타일을 우선 재사용한다. +- 긴 텍스트 메시지는 기존 채팅 말풍선의 줄바꿈/최대 폭 정책을 따른다. +- 키보드가 올라와도 입력 영역과 최신 메시지가 가려지지 않아야 한다. + +--- + +## 9. Technical Constraints +- Android XML Views, ViewBinding, RecyclerView, RxJava3, Retrofit, Gson, Koin 구조를 따른다. +- 신규 `Activity`, `Fragment`, `ViewModel` 및 연결 하위 코드는 저장소 규칙에 따라 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다. +- DM 화면은 신규 Activity로 생성한다. +- 기존 `ChatRoomActivity`는 직접 변형하지 않는다. 필요한 경우 메시지 item/adapter/model 등 공용화 가능한 최소 컴포넌트만 구현 계획에서 검토한다. +- REST API 응답은 기존 패턴처럼 `Single>`를 우선 사용한다. SSE 연결은 `text/event-stream` 수신으로 처리하며, `connected`/`message` event 파싱 방식을 구현 계획에서 확정한다. +- token 전달은 기존 v2 API 패턴과 `SharedPreferenceManager.token` 사용 방식을 따른다. Repository 구현 시 `Authorization` 헤더 문자열은 단일 bearer helper로 생성해 오입력을 방지한다. +- 앱 foreground/background 감지는 Activity lifecycle과 앱 전체 `ProcessLifecycleOwner` 중 어떤 기준을 사용할지 구현 계획에서 확정한다. +- 구현 전 `docs/20260610_DM_채팅화면/plan-task.md`를 작성하고, 그 문서에 따라 최소 구현한다. +- 테스트는 DTO mapper, 메시지 정렬/중복 제거, pagination state, disconnect lifecycle을 우선 검증한다. + +--- + +## 10. API Summary + +| 기능 | Method | Path | Request | Response | +| --- | --- | --- | --- | --- | +| 방 생성 또는 조회 | `POST` | `/api/v2/user-creator-chat/rooms/create` | `CreateUserCreatorChatRoomRequest` | `CreateUserCreatorChatRoomResponse` | +| 생성된 채팅방 열기 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/open?limit=20` | 없음 | `UserCreatorChatRoomOpenResponse` | +| SSE 연결 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/events` | 없음 | `text/event-stream`: `connected`(`String`), `message`(`UserCreatorChatMessageItemDto` JSON) | +| 과거 메시지 조회 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={cursor}&limit=20` | 없음 | `UserCreatorChatMessagesPageResponse` | +| 텍스트 메시지 보내기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/messages/text` | `SendUserCreatorTextMessageRequest` | `SendUserCreatorChatMessageResponse` | +| SSE 연결 끊기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` | 없음 | `ApiResponse` | + +--- + +## 11. Metrics +- `creatorId` 기반 진입 시 CreateOrGetRoom 성공 후 반환된 `roomId`로 DM 채팅방이 열린다. +- `roomId` 기반 진입 시 OpenRoom이 `limit=20`으로 호출된다. +- OpenRoom 응답 메시지가 시간순으로 `RecyclerView`에 표시된다. +- OpenRoom 응답의 `opponentNickname`, `opponentProfileImageUrl`이 header 상대 정보로 표시된다. +- 상단 스크롤 시 `hasMore=true`와 `nextCursor` 조건에 따라 GetMessages가 호출된다. +- 텍스트 메시지 전송 성공 시 응답의 `message`가 화면에 반영된다. +- 텍스트 메시지 전송 실패 시 낙관적으로 추가된 메시지가 실패 상태와 재시도 버튼을 표시한다. +- 화면 이탈 또는 앱 background 전환 시 DisconnectRealtime 호출이 발생한다. +- SSE 재연결 기본 간격은 서버 `reconnectTime=3000`ms를 따른다. +- SSE 재연결 후 필요 시 GetMessages API로 누락 메시지를 동기화한다. +- disconnect 처리는 UI thread를 블로킹하지 않는다. +- 제거 대상 UI(`character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`)가 DM 화면에 나타나지 않는다. + +--- + +## 12. Open Questions +- DM 채팅방 진입점별 intent extra 이름은 구현 계획에서 확정한다. +- 음성 메시지 UI/UX와 `VOICE` 메시지 표시 방식은 후속 범위에서 확정한다. + +--- + +## 13. References +- 기존 AI 채팅방 Activity: `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt` +- 기존 AI 채팅방 layout: `app/src/main/res/layout/activity_chat_room.xml` +- 기존 AI 채팅 API: `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt` +- 기존 AI 채팅 Repository: `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt` +- v2 채팅 탭 API: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomApi.kt` +- v2 채팅 탭 모델: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomModels.kt` +- 관련 PRD: `docs/20260609_채팅_탭_페이지/prd.md` +- PRD 템플릿: `docs/prd/sample-prd.md` +- 작업 문서 규칙: `docs/agent-guides/work-plan-docs.md` + +--- + +## 14. Verification Log +- 2026-06-10: `docs/prd/sample-prd.md`, `docs/agent-guides/work-plan-docs.md`, `docs/20260609_채팅_탭_페이지/prd.md`를 확인해 PRD 구조, 신규 문서 경로 규칙, 검증 기록 누적 방식을 확인했다. +- 2026-06-10: `activity_chat_room.xml`을 확인해 제거 대상 UI인 `tv_character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`의 실제 위치와 메시지 영역 constraint를 확인했다. +- 2026-06-10: `ChatRoomActivity.kt`, `TalkApi.kt`, `ChatRepository.kt`를 확인해 기존 채팅방의 초기 로드, 상단 pagination, 텍스트 전송, 입력 UI, header/notice/쿼터 구조를 분석했다. +- 2026-06-10: `ChatRoomApi.kt`, `ChatRoomModels.kt` 및 `docs/20260609_채팅_탭_페이지/prd.md`를 확인해 v2 채팅 탭의 패키지/DTO/Retrofit 패턴과 DM 상세 화면 연결 배경을 확인했다. +- 2026-06-10: `rg`로 `EventSource`, `SSE`, `text/event-stream`, `ResponseBody`, `events/disconnect` 등을 검색했으며 현재 저장소에는 재사용 가능한 SSE 구현 패턴이 없는 것으로 확인했다. 따라서 SSE 구현 방식은 후속 `plan-task.md`에서 별도 검증/결정할 항목으로 남겼다. +- 2026-06-10: 이번 단계는 PRD 문서 생성만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다. +- 2026-06-10: 백그라운드 조사 결과를 반영해 REST pagination과 realtime 수신의 `messageId` 기준 중복 제거, keyset cursor 의미, 전송 중 연타 방지, 과거 페이지를 보고 있을 때 전송 처리 기준, SSE cancel/close, failure 상태 전환, backoff/jitter 재연결, UI thread 비차단, disconnect와 로그아웃 캐시 삭제 범위 구분을 보강했다. +- 2026-06-10: 사용자 확정 사항을 반영해 텍스트 전송 실패 UX를 낙관적 UI와 재시도 버튼으로 확정하고, DM 화면은 신규 Activity로 생성하도록 명시했다. OpenRoom 응답에 `opponentNickname`, `opponentProfileImageUrl`을 추가했으며, `messageType` 가능한 값은 `TEXT`, `VOICE`로 확정하고 이번 구현은 텍스트 메시지만 대상으로 제한했다. +- 2026-06-10: 사용자 제공 백엔드 계약을 반영해 SSE 이벤트 이름과 payload를 확정했다. `connected` 이벤트는 `"connected"` 문자열 handshake로, `message` 이벤트는 `UserCreatorChatMessageItemDto` JSON으로 문서화했다. 서버 `reconnectTime=3000`ms, `Last-Event-ID` 기반 replay 미지원, 재연결 후 GetMessages API를 통한 누락 메시지 보정 요구사항도 반영했다. +- 2026-06-10: 백그라운드 조사 결과와 대조해 `ConnectEvents` 응답 표기를 `ApiResponse`에서 `text/event-stream`으로 보정하고, API Summary와 기술 제약도 SSE stream 수신/파싱 기준으로 갱신했다. +- 2026-06-10: 후속 Repository 구현 시 `Authorization` 헤더 오입력 방지를 위해 단일 bearer helper로 헤더 문자열을 생성하도록 기술 제약에 기록했다.