From 88e3ae7b511353871349a1af091bb9c886217c39 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 27 Aug 2025 15:53:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=EB=B0=B0=EA=B2=BD=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B4=88=EA=B8=B0=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=EC=9D=B4=20=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택 상태를 URL 비교에서 이미지 ID 우선 방식으로 변경 - URL만 저장된 기존 데이터에 대해 목록 로드 후 URL→ID 마이그레이션 추가 - SharedPreferences에 chat_bg_image_id_room_{roomId} 키 도입(호환 위해 URL 키 유지) --- .../vividnext/sodalive/chat/talk/TalkApi.kt | 3 +- .../ChatBackgroundPickerDialogFragment.kt | 51 +++++++------------ .../sodalive/chat/talk/room/ChatRepository.kt | 4 +- .../chat/talk/room/ChatRoomActivity.kt | 24 +++++++-- .../chat/talk/room/ChatRoomEnterResponse.kt | 3 +- .../chat/talk/room/ChatRepositoryTest.kt | 4 +- 6 files changed, 44 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt index 13d5b2a7..74d1cc3b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt @@ -42,7 +42,8 @@ interface TalkApi { @GET("/api/chat/room/{roomId}/enter") fun enterChatRoom( @Header("Authorization") authHeader: String, - @Path("roomId") roomId: Long + @Path("roomId") roomId: Long, + @Query("characterImageId") characterImageId: Long? ): Single> // 메시지 전송 API diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt index f4130e4e..794fa322 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt @@ -42,12 +42,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { private var profileUrl: String = "" private val prefsName = "chat_room_prefs" - private fun bgUrlKey(roomId: Long) = "chat_bg_url_room_$roomId" private fun bgImageIdKey(roomId: Long) = "chat_bg_image_id_room_$roomId" private lateinit var adapter: BgAdapter private val items = mutableListOf() - private var selectedUrl: String? = null private var selectedId: Long? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -88,12 +86,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { } private fun loadData() { - // 초기 선택: 저장된 값 사용 (URL + ID 병행) + // 초기 선택: 저장된 이미지 ID 사용 val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) - selectedUrl = prefs.getString(bgUrlKey(roomId), null) val savedId = prefs.getLong(bgImageIdKey(roomId), -1L) selectedId = if (savedId > 0) savedId else null - items.clear() if (characterId > 0) { @@ -105,38 +101,23 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { .subscribe({ resp -> val list = resp.data?.items.orEmpty() items.addAll(list.map { BgItem(id = it.id, url = it.imageUrl) }) - adapter.submit(items, selectedId, selectedUrl) + adapter.submit(items, selectedId) - // 마이그레이션: 과거에 URL만 저장되어 있고 ID가 비어 있는 경우 - if (selectedId == null && !selectedUrl.isNullOrBlank()) { - val found = items.firstOrNull { it.url == selectedUrl } - if (found != null) { - selectedId = found.id - // UI 갱신 - adapter.updateSelected(found) - // 영구 저장(ID 동기화) - val p = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) - p.edit { - putLong(bgImageIdKey(roomId), found.id) - } - } - } loadingDialog.dismiss() }, { _ -> // 실패 시에도 현재까지의 목록 표시(없으면 빈 목록) - adapter.submit(items, selectedId, selectedUrl) + adapter.submit(items, selectedId) loadingDialog.dismiss() }) compositeDisposable.add(d) } else { // characterId가 없으면 서버 요청 불가: 현재 저장된 선택 상태만 반영 - adapter.submit(items, selectedId, selectedUrl) + adapter.submit(items, selectedId) } } private fun onSelect(item: BgItem) { - selectedUrl = item.url selectedId = item.id.takeIf { it > 0 } saveAndApply(item) adapter.updateSelected(item) @@ -145,8 +126,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { private fun saveAndApply(item: BgItem) { val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) prefs.edit { - putString(bgUrlKey(roomId), item.url) - if (item.id > 0) putLong(bgImageIdKey(roomId), item.id) else remove(bgImageIdKey(roomId)) + if (item.id > 0) putLong( + bgImageIdKey(roomId), + item.id + ) else remove(bgImageIdKey(roomId)) } (activity as? ChatRoomActivity)?.setChatBackground(item.url) } @@ -155,7 +138,8 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { super.onDestroyView() try { if (this::loadingDialog.isInitialized) loadingDialog.dismiss() - } catch (_: Throwable) { } + } catch (_: Throwable) { + } compositeDisposable.clear() _binding = null } @@ -166,22 +150,19 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { private val onClick: (BgItem) -> Unit ) : RecyclerView.Adapter() { private val data = mutableListOf() - private var selectedUrl: String? = null private var selectedId: Long? = null @SuppressLint("NotifyDataSetChanged") - fun submit(items: List, selectedId: Long?, selectedUrl: String?) { + fun submit(items: List, selectedId: Long?) { data.clear() data.addAll(items) this.selectedId = selectedId - this.selectedUrl = selectedUrl notifyDataSetChanged() } @SuppressLint("NotifyDataSetChanged") fun updateSelected(item: BgItem) { this.selectedId = item.id.takeIf { it > 0 } - this.selectedUrl = item.url notifyDataSetChanged() } @@ -195,7 +176,7 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { override fun getItemCount(): Int = data.size override fun onBindViewHolder(holder: BgVH, position: Int) { - holder.bind(data[position], selectedId, selectedUrl) + holder.bind(data[position], selectedId) } } @@ -203,13 +184,15 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { private val binding: ItemChatBackgroundImageBinding, private val onClick: (BgItem) -> Unit ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: BgItem, selectedId: Long?, selectedUrl: String?) { + fun bind(item: BgItem, selectedId: Long?) { binding.ivImage.load(item.url) { placeholder(R.drawable.ic_placeholder_profile) error(R.drawable.ic_placeholder_profile) } - val selected = (selectedId != null && item.id == selectedId) || - (selectedUrl != null && selectedUrl == item.url) + val selected = when { + selectedId != null -> item.id == selectedId + else -> item.id == 0L // 저장된 ID가 없으면 프로필(id=0)을 선택으로 간주 + } binding.tvCurrent.visibility = if (selected) View.VISIBLE else View.GONE binding.viewBorder.visibility = if (selected) View.VISIBLE else View.GONE binding.root.setOnClickListener { onClick(item) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt index 1fc23b48..112d198c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt @@ -86,8 +86,8 @@ class ChatRepository( * 통합 채팅방 입장: 서버 캐릭터 정보 + 최신 메시지 수신 후 로컬 DB 업데이트 * - 로컬 데이터가 없더라도 서버 응답을 기준으로 동기화 */ - fun enterChatRoom(token: String, roomId: Long): Single { - return talkApi.enterChatRoom(authHeader = token, roomId = roomId) + fun enterChatRoom(token: String, roomId: Long, characterImageId: Long?): Single { + return talkApi.enterChatRoom(authHeader = token, roomId = roomId, characterImageId = characterImageId) .subscribeOn(Schedulers.io()) .flatMap { response -> val data = ensureSuccess(response) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt index 721ed0bb..ce322dca 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt @@ -96,8 +96,7 @@ class ChatRoomActivity : BaseActivity( binding.tvName.text = "" binding.ivProfile.setImageResource(R.drawable.ic_placeholder_profile) binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile) - // 저장된 배경이 있으면 즉시 적용 (네트워크 응답 전 초기 진입에서도 반영) - applyBackgroundFromPrefsOrProfile("") + // 배경은 서버 응답 수신 시 적용 (기본은 플레이스홀더 유지) // 배지는 기본 Clone으로 둔다가 실제 값으로 갱신 (디자인 기본 배경도 clone) binding.tvCharacterTypeBadge.text = "Clone" binding.tvCharacterTypeBadge.setBackgroundResource(R.drawable.bg_character_status_clone) @@ -121,8 +120,7 @@ class ChatRoomActivity : BaseActivity( // 프로필 이미지 (공용 유틸 + 둥근 모서리 적용) loadProfileImage(binding.ivProfile, info.profileImageUrl) - // 배경 이미지: 저장된 값 우선, 없으면 프로필로 저장/적용 - applyBackgroundFromPrefsOrProfile(info.profileImageUrl) + // 배경 이미지는 서버 응답의 backgroundImageUrl을 우선 적용 (enter 완료 시 처리) // 타입 배지 텍스트 및 배경 val (badgeText, badgeBg) = when (info.characterType) { @@ -607,12 +605,21 @@ class ChatRoomActivity : BaseActivity( // 2) 서버 통합 API로 동기화 및 UI 갱신 val token = "Bearer ${SharedPreferenceManager.token}" - val networkDisposable = chatRepository.enterChatRoom(token = token, roomId = roomId) + val bgImageId = getSavedBackgroundImageId() + val networkDisposable = chatRepository.enterChatRoom(token = token, roomId = roomId, characterImageId = bgImageId) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ response -> // 캐릭터 정보 바인딩 setCharacterInfo(response.character) + // 배경 이미지 적용: 서버 응답의 backgroundImageUrl 우선, 없으면 프로필 사용 + val bgUrl = response.backgroundImageUrl ?: response.character.profileImageUrl + if (bgUrl.isNotBlank()) { + setChatBackground(bgUrl) + } else { + binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile) + } + // 메시지 정렬(오래된 -> 최신) + 동시간대는 messageId 오름차순으로 안정화 val sorted = response.messages.sortedWith(compareBy { it.createdAt }.thenBy { it.messageId }) @@ -870,6 +877,13 @@ class ChatRoomActivity : BaseActivity( fun getCharacterProfileUrl(): String = characterInfo?.profileImageUrl ?: "" + private fun bgImageIdPrefKey(): String = "chat_bg_image_id_room_$roomId" + + private fun getSavedBackgroundImageId(): Long? { + val id = prefs.getLong(bgImageIdPrefKey(), -1L) + return if (id > 0) id else null + } + fun onResetChatRequested() { val title = "대화 초기화" val desc = "지금까지의 대화가 모두 초기화 되고 새롭게 대화를 시작합니다." diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt index 0630e772..802d9402 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt @@ -13,5 +13,6 @@ data class ChatRoomEnterResponse( @SerializedName("messages") val messages: List, @SerializedName("hasMoreMessages") val hasMoreMessages: Boolean, @SerializedName("totalRemaining") val totalRemaining: Int = 0, - @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null + @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null, + @SerializedName("bgImageUrl") val backgroundImageUrl: String? = null ) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt index 72b0f507..cc0d15d5 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt @@ -27,10 +27,10 @@ class ChatRepositoryTest { val character = CharacterInfo(10, "name", "", CharacterType.CLONE) val resp = ChatRoomEnterResponse(99, character, serverMessages, hasMoreMessages = false) - every { api.enterChatRoom(any(), any()) } returns Single.just(ApiResponse(true, resp, null)) + every { api.enterChatRoom(any(), any(), any()) } returns Single.just(ApiResponse(true, resp, null)) coEvery { dao.getNthLatestCreatedAt(any(), any()) } returns null - val result = repo.enterChatRoom("token", 99).blockingGet() + val result = repo.enterChatRoom("token", 99, null).blockingGet() // 반환 검증 assertEquals(99, result.roomId)