From 0fcd929c6f3c4f4fbf7b111c5949c1ef009ee4cb Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 27 Mar 2026 17:33:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(content):=20=EA=B5=AD=EA=B0=80=EB=B3=84=20?= =?UTF-8?q?=EC=84=B1=EC=9D=B8=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EB=8F=99=EA=B8=B0=ED=99=94=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/CharacterTabFragment.kt | 14 +- .../co/vividnext/sodalive/common/Constants.kt | 2 + .../common/SharedPreferenceManager.kt | 8 +- .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 2 +- .../vividnext/sodalive/home/HomeFragment.kt | 51 +++- .../vividnext/sodalive/live/LiveFragment.kt | 39 ++- .../live/now/all/LiveNowAllActivity.kt | 39 ++- .../sodalive/live/room/LiveRoomActivity.kt | 37 ++- .../sodalive/live/room/LiveRoomViewModel.kt | 9 +- .../vividnext/sodalive/main/MainViewModel.kt | 3 + .../sodalive/mypage/MyPageFragment.kt | 37 ++- .../settings/ContentSettingsActivity.kt | 70 ++++- .../settings/ContentSettingsViewModel.kt | 194 +++++++++++- .../sodalive/settings/SettingsActivity.kt | 3 +- .../UpdateContentPreferenceRequest.kt | 10 + .../UpdateContentPreferenceResponse.kt | 10 + .../notification/GetMemberInfoResponse.kt | 11 +- .../kr/co/vividnext/sodalive/user/UserApi.kt | 9 + .../vividnext/sodalive/user/UserRepository.kt | 9 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + ..._멤버정보응답확장및콘텐츠보기설정동기화.md | 284 ++++++++++++++++++ 23 files changed, 757 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceResponse.kt create mode 100644 docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt index 648760a3..22b15cba 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt @@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Co import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllAdapter import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter +import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.databinding.FragmentCharacterTabBinding @@ -33,6 +34,7 @@ import kr.co.vividnext.sodalive.main.MainActivity import kr.co.vividnext.sodalive.mypage.MyPageViewModel import kr.co.vividnext.sodalive.mypage.auth.Auth import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest +import kr.co.vividnext.sodalive.settings.ContentSettingsActivity import kr.co.vividnext.sodalive.splash.SplashActivity import org.koin.android.ext.android.inject @@ -375,7 +377,8 @@ class CharacterTabFragment : BaseFragment( return } - if (!SharedPreferenceManager.isAuth) { + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + if (isKoreanCountry && !SharedPreferenceManager.isAuth) { SodaDialog( activity = requireActivity(), layoutInflater = layoutInflater, @@ -390,6 +393,15 @@ class CharacterTabFragment : BaseFragment( return } + if (!SharedPreferenceManager.isAdultContentVisible) { + startActivity( + Intent(requireContext(), ContentSettingsActivity::class.java).apply { + putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true) + } + ) + return + } + onAuthed() } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt index 91ce1882..cec2985c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt @@ -7,6 +7,7 @@ object Constants { const val PREF_EMAIL = "pref_email" const val PREF_USER_ID = "pref_user_id" const val PREF_IS_ADULT = "pref_is_adult" + const val PREF_COUNTRY_CODE = "pref_country_code" const val PREF_NICKNAME = "pref_nickname" const val PREF_USER_ROLE = "pref_user_role" const val PREF_NO_CHAT_ROOM = "pref_no_chat" @@ -81,6 +82,7 @@ object Constants { const val EXTRA_AUDIO_CONTENT_PLAYLIST = "extra_audio_content_playlist" const val EXTRA_PLAYLIST_SEGMENT_LOOP_IMAGE = "extra_playlist_segment_loop_image" const val EXTRA_IS_SHOW_SECRET = "extra_is_show_secret" + const val EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE = "extra_show_sensitive_content_guide" const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2 const val ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver" diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt index fcfa8301..82f693ef 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt @@ -220,6 +220,12 @@ object SharedPreferenceManager { setPreference(Constants.PREF_IS_ADULT, value) } + var countryCode: String + get() = getPreference(Constants.PREF_COUNTRY_CODE, "KR") + set(value) { + setPreference(Constants.PREF_COUNTRY_CODE, value) + } + var isAuditionNotification: Boolean get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false) set(value) { @@ -227,7 +233,7 @@ object SharedPreferenceManager { } var isAdultContentVisible: Boolean - get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true) + get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, false) set(value) { setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 6c50f322..ab246359 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -331,7 +331,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { EventViewModel(get()) } viewModel { NotificationSettingsViewModel(get()) } viewModel { NotificationReceiveSettingsViewModel(get(), get()) } - viewModel { ContentSettingsViewModel() } + viewModel { ContentSettingsViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { SeriesDetailViewModel(get(), get()) } viewModel { SeriesListAllViewModel(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt index b9b2c03f..3988b036 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt @@ -61,6 +61,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.search.SearchActivity +import kr.co.vividnext.sodalive.settings.ContentSettingsActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LocaleHelper @@ -1339,7 +1340,8 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl return } - if (!SharedPreferenceManager.isAuth) { + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + if (isKoreanCountry && !SharedPreferenceManager.isAuth) { SodaDialog( activity = requireActivity(), layoutInflater = layoutInflater, @@ -1354,6 +1356,15 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl return } + if (!SharedPreferenceManager.isAdultContentVisible) { + startActivity( + Intent(requireContext(), ContentSettingsActivity::class.java).apply { + putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true) + } + ) + return + } + onAuthed() } @@ -1363,19 +1374,31 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl return } - if (isAdult && !SharedPreferenceManager.isAuth) { - SodaDialog( - activity = requireActivity(), - layoutInflater = layoutInflater, - title = getString(R.string.auth_title), - desc = getString(R.string.auth_desc_live), - confirmButtonTitle = getString(R.string.auth_go), - confirmButtonClick = { startAuthFlow() }, - cancelButtonTitle = getString(R.string.cancel), - cancelButtonClick = {}, - descGravity = Gravity.CENTER - ).show(screenWidth) - return + if (isAdult) { + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + if (isKoreanCountry && !SharedPreferenceManager.isAuth) { + SodaDialog( + activity = requireActivity(), + layoutInflater = layoutInflater, + title = getString(R.string.auth_title), + desc = getString(R.string.auth_desc_live), + confirmButtonTitle = getString(R.string.auth_go), + confirmButtonClick = { startAuthFlow() }, + cancelButtonTitle = getString(R.string.cancel), + cancelButtonClick = {}, + descGravity = Gravity.CENTER + ).show(screenWidth) + return + } + + if (!SharedPreferenceManager.isAdultContentVisible) { + startActivity( + Intent(requireContext(), ContentSettingsActivity::class.java).apply { + putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true) + } + ) + return + } } onAuthed() diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt index 33546bfd..4fb9b0ff 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt @@ -65,6 +65,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.search.SearchActivity +import kr.co.vividnext.sodalive.settings.ContentSettingsActivity import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.settings.notification.MemberRole @@ -862,19 +863,31 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl return } - if (isAdult && !SharedPreferenceManager.isAuth) { - SodaDialog( - activity = requireActivity(), - layoutInflater = layoutInflater, - title = getString(R.string.auth_title), - desc = getString(R.string.auth_desc_live), - confirmButtonTitle = getString(R.string.auth_go), - confirmButtonClick = { startAuthFlow() }, - cancelButtonTitle = getString(R.string.cancel), - cancelButtonClick = {}, - descGravity = Gravity.CENTER - ).show(screenWidth) - return + if (isAdult) { + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + if (isKoreanCountry && !SharedPreferenceManager.isAuth) { + SodaDialog( + activity = requireActivity(), + layoutInflater = layoutInflater, + title = getString(R.string.auth_title), + desc = getString(R.string.auth_desc_live), + confirmButtonTitle = getString(R.string.auth_go), + confirmButtonClick = { startAuthFlow() }, + cancelButtonTitle = getString(R.string.cancel), + cancelButtonClick = {}, + descGravity = Gravity.CENTER + ).show(screenWidth) + return + } + + if (!SharedPreferenceManager.isAdultContentVisible) { + startActivity( + Intent(requireContext(), ContentSettingsActivity::class.java).apply { + putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true) + } + ) + return + } } onAuthed() diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/now/all/LiveNowAllActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/now/all/LiveNowAllActivity.kt index b27a41d3..347323f5 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/now/all/LiveNowAllActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/now/all/LiveNowAllActivity.kt @@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.mypage.MyPageViewModel import kr.co.vividnext.sodalive.mypage.auth.Auth import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse +import kr.co.vividnext.sodalive.settings.ContentSettingsActivity import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.splash.SplashActivity @@ -119,19 +120,31 @@ class LiveNowAllActivity : BaseActivity( return } - if (isAdult && !SharedPreferenceManager.isAuth) { - SodaDialog( - activity = this, - layoutInflater = layoutInflater, - title = getString(R.string.auth_title), - desc = getString(R.string.auth_desc_live), - confirmButtonTitle = getString(R.string.auth_go), - confirmButtonClick = { startAuthFlow() }, - cancelButtonTitle = getString(R.string.cancel), - cancelButtonClick = {}, - descGravity = Gravity.CENTER - ).show(screenWidth) - return + if (isAdult) { + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + if (isKoreanCountry && !SharedPreferenceManager.isAuth) { + SodaDialog( + activity = this, + layoutInflater = layoutInflater, + title = getString(R.string.auth_title), + desc = getString(R.string.auth_desc_live), + confirmButtonTitle = getString(R.string.auth_go), + confirmButtonClick = { startAuthFlow() }, + cancelButtonTitle = getString(R.string.cancel), + cancelButtonClick = {}, + descGravity = Gravity.CENTER + ).show(screenWidth) + return + } + + if (!SharedPreferenceManager.isAdultContentVisible) { + startActivity( + Intent(applicationContext, ContentSettingsActivity::class.java).apply { + putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true) + } + ) + return + } } onAuthed() diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt index 7608ba50..627ab3dd 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt @@ -124,6 +124,7 @@ import kr.co.vividnext.sodalive.main.MainActivity import kr.co.vividnext.sodalive.report.ProfileReportDialog import kr.co.vividnext.sodalive.report.ReportType import kr.co.vividnext.sodalive.report.UserReportDialog +import kr.co.vividnext.sodalive.settings.ContentSettingsActivity import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.notification.MemberRole import org.json.JSONObject @@ -1053,17 +1054,35 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } viewModel.changeIsAdultLiveData.observe(this) { - if (it && !SharedPreferenceManager.isAuth) { + if (it) { + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + val shouldBlockByAuth = isKoreanCountry && !SharedPreferenceManager.isAuth + val shouldBlockBySensitiveContent = !isKoreanCountry && !SharedPreferenceManager.isAdultContentVisible + if (!shouldBlockByAuth && !shouldBlockBySensitiveContent) { + return@observe + } + agora.muteAllRemoteAudioStreams(true) binding.rvChat.visibility = View.INVISIBLE - SodaDialog( - this@LiveRoomActivity, - layoutInflater, - getString(R.string.screen_live_room_age_limit_title), - getString(R.string.screen_live_room_age_limit_message), - getString(R.string.screen_live_room_ok), - { finish() } - ).show(screenWidth) + + if (shouldBlockBySensitiveContent) { + showToast(getString(R.string.screen_content_settings_sensitive_content_guide)) + startActivity( + Intent(applicationContext, ContentSettingsActivity::class.java).apply { + putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true) + } + ) + finish() + } else { + SodaDialog( + this@LiveRoomActivity, + layoutInflater, + getString(R.string.screen_live_room_age_limit_title), + getString(R.string.screen_live_room_age_limit_message), + getString(R.string.screen_live_room_ok), + { finish() } + ).show(screenWidth) + } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt index 408ef3ee..de38bb04 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt @@ -250,7 +250,14 @@ class LiveRoomViewModel( getTotalDonationCan(roomId = roomId) getTotalHeart(roomId = roomId) - if (it.data.isAdult && !SharedPreferenceManager.isAuth) { + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + val isAdultContentBlocked = if (isKoreanCountry) { + !SharedPreferenceManager.isAuth + } else { + !SharedPreferenceManager.isAdultContentVisible + } + + if (it.data.isAdult && isAdultContentBlocked) { _changeIsAdultLiveData.value = true } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt index 42dca50a..06cc7dad 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt @@ -103,6 +103,9 @@ class MainViewModel( SharedPreferenceManager.point = data.point SharedPreferenceManager.role = data.role.name SharedPreferenceManager.isAuth = data.isAuth + SharedPreferenceManager.countryCode = data.countryCode.ifBlank { "KR" } + SharedPreferenceManager.isAdultContentVisible = data.isAdultContentVisible + SharedPreferenceManager.contentPreference = data.contentType.ordinal SharedPreferenceManager.isAuditionNotification = data.auditionNotice ?: false if ( diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt index c17c6e45..974b2325 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt @@ -380,13 +380,30 @@ class MyPageFragment : BaseFragment(FragmentMyBinding::inflat } viewModel.myPageLiveData.observe(viewLifecycleOwner) { - if (it.isAuth) { - FunctionButtonHelper.setupFunctionButton( - buttonView = binding.btnIdentityVerification.root, - iconRes = R.drawable.ic_my_auth, - title = getString(R.string.screen_my_identity_verified) - ) + val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + if (isKoreanUser) { + binding.btnIdentityVerification.root.visibility = View.VISIBLE + if (it.isAuth) { + FunctionButtonHelper.setupFunctionButton( + buttonView = binding.btnIdentityVerification.root, + iconRes = R.drawable.ic_my_auth, + title = getString(R.string.screen_my_identity_verified) + ) + } else { + FunctionButtonHelper.setupFunctionButton( + buttonView = binding.btnIdentityVerification.root, + iconRes = R.drawable.ic_my_auth, + title = getString(R.string.screen_my_identity_verification) + ) { + showAuthDialog() + } + } + } else { + binding.btnIdentityVerification.root.visibility = View.GONE + } + + if (it.isAuth) { FunctionButtonHelper.setupFunctionButton( buttonView = binding.btnCoupon.root, iconRes = R.drawable.ic_my_coupon, @@ -400,14 +417,6 @@ class MyPageFragment : BaseFragment(FragmentMyBinding::inflat ) } } else { - FunctionButtonHelper.setupFunctionButton( - buttonView = binding.btnIdentityVerification.root, - iconRes = R.drawable.ic_my_auth, - title = getString(R.string.screen_my_identity_verification) - ) { - showAuthDialog() - } - FunctionButtonHelper.setupFunctionButton( buttonView = binding.btnCoupon.root, iconRes = R.drawable.ic_my_coupon, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt index a618581f..9dab8294 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt @@ -3,9 +3,13 @@ package kr.co.vividnext.sodalive.settings import android.content.Intent import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.activity.OnBackPressedCallback import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.base.BaseActivity +import kr.co.vividnext.sodalive.base.SodaDialog +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.databinding.ActivityContentSettingsBinding import kr.co.vividnext.sodalive.splash.SplashActivity @@ -16,37 +20,74 @@ class ContentSettingsActivity : BaseActivity( ) { private val viewModel: ContentSettingsViewModel by inject() + private lateinit var loadingDialog: LoadingDialog + private val sensitiveContentConfirmDialog: SodaDialog by lazy { + SodaDialog( + activity = this, + layoutInflater = layoutInflater, + title = getString(R.string.dialog_sensitive_content_enable_title), + desc = getString(R.string.dialog_sensitive_content_enable_message), + confirmButtonTitle = getString(R.string.screen_live_room_yes), + confirmButtonClick = { viewModel.toggleAdultContentVisible() }, + cancelButtonTitle = getString(R.string.screen_live_room_no), + cancelButtonClick = {} + ) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) bindData() - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - handleFinish() + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + handleFinish() + } } - }) + ) } override fun setupView() { + loadingDialog = LoadingDialog(this, layoutInflater) + binding.toolbar.tvBack.text = getString(R.string.screen_content_settings_title) binding.toolbar.tvBack.setOnClickListener { handleFinish() } + if (intent.getBooleanExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, false)) { + Toast.makeText( + applicationContext, + getString(R.string.screen_content_settings_sensitive_content_guide), + Toast.LENGTH_LONG + ).show() + } + + val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR" + val canControlSensitiveContent = if (isKoreanCountry) { + SharedPreferenceManager.isAuth + } else { + true + } + // 본인 인증 체크 - if (SharedPreferenceManager.isAuth) { + if (canControlSensitiveContent) { binding.llAdultContentVisible.visibility = View.VISIBLE // 19금 콘텐츠 보기 체크 if (SharedPreferenceManager.isAdultContentVisible) { binding.llAdultContentPreference.visibility = View.VISIBLE - } else { binding.llAdultContentPreference.visibility = View.GONE } // 19금 콘텐츠 보기 스위치 액션 binding.ivAdultContentVisible.setOnClickListener { - viewModel.toggleAdultContentVisible() + val isAdultContentVisible = viewModel.isAdultContentVisible.value == true + if (isAdultContentVisible) { + viewModel.toggleAdultContentVisible() + } else { + sensitiveContentConfirmDialog.show(screenWidth) + } } binding.tvContentAll.setOnClickListener { @@ -88,6 +129,21 @@ class ContentSettingsActivity : BaseActivity( else -> {} } } + + viewModel.isLoading.observe(this) { + if (it) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + + viewModel.toastLiveData.observe(this) { + val text = it?.message ?: it?.resId?.let { resId -> getString(resId) } + text?.let { message -> + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + } + } } private fun handleFinish() { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt index 79a20388..351068cc 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt @@ -1,39 +1,201 @@ package kr.co.vividnext.sodalive.settings +import android.os.Handler +import android.os.Looper import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.common.ToastMessage +import kr.co.vividnext.sodalive.user.UserRepository -class ContentSettingsViewModel : BaseViewModel() { - private var _isAdultContentVisible = MutableLiveData( - SharedPreferenceManager.isAdultContentVisible +class ContentSettingsViewModel( + private val userRepository: UserRepository +) : BaseViewModel() { + private data class PreferenceState( + val isAdultContentVisible: Boolean, + val contentType: ContentType ) + + private val handler = Handler(Looper.getMainLooper()) + private val initialState = getCurrentPreferenceState() + private var confirmedState = initialState + private var isRequestInFlight = false + private var pendingState: PreferenceState? = null + private var syncRunnable: Runnable? = null + + private val _isAdultContentVisible = MutableLiveData(initialState.isAdultContentVisible) val isAdultContentVisible: LiveData get() = _isAdultContentVisible - private var _adultContentPreference = MutableLiveData( - ContentType.values()[SharedPreferenceManager.contentPreference] - ) + private val _adultContentPreference = MutableLiveData(initialState.contentType) val adultContentPreference: LiveData get() = _adultContentPreference + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + var isChangedAdultContentVisible = false + private set fun toggleAdultContentVisible() { - val adultContentVisible = SharedPreferenceManager.isAdultContentVisible - _isAdultContentVisible.value = !adultContentVisible - SharedPreferenceManager.isAdultContentVisible = !adultContentVisible - isChangedAdultContentVisible = true + val currentState = getCurrentPreferenceState() + val nextState = PreferenceState( + isAdultContentVisible = !currentState.isAdultContentVisible, + contentType = if (currentState.isAdultContentVisible) { + ContentType.ALL + } else { + currentState.contentType + } + ) - if (adultContentVisible) { - SharedPreferenceManager.contentPreference = ContentType.ALL.ordinal - } + applyLocalState(nextState) + queueLatestStateForSync() } fun setAdultContentPreference(adultContentPreference: ContentType) { - _adultContentPreference.value = adultContentPreference - SharedPreferenceManager.contentPreference = adultContentPreference.ordinal - isChangedAdultContentVisible = true + val currentState = getCurrentPreferenceState() + if (currentState.contentType == adultContentPreference) { + return + } + + applyLocalState( + currentState.copy(contentType = adultContentPreference) + ) + queueLatestStateForSync() + } + + private fun queueLatestStateForSync() { + pendingState = getCurrentPreferenceState() + + syncRunnable?.let { handler.removeCallbacks(it) } + syncRunnable = Runnable { + flushPendingState() + }.also { + handler.postDelayed(it, CONTENT_PREFERENCE_DEBOUNCE_DELAY_MS) + } + } + + private fun flushPendingState() { + if (isRequestInFlight) { + return + } + + val targetState = pendingState ?: return + if (targetState == confirmedState) { + pendingState = null + return + } + + pendingState = null + syncContentPreference(targetState) + } + + private fun syncContentPreference(targetState: PreferenceState) { + val request = UpdateContentPreferenceRequest( + isAdultContentVisible = if (targetState.isAdultContentVisible != confirmedState.isAdultContentVisible) { + targetState.isAdultContentVisible + } else { + null + }, + contentType = if (targetState.contentType != confirmedState.contentType) { + targetState.contentType + } else { + null + } + ) + + if (request.isAdultContentVisible == null && request.contentType == null) { + return + } + + isRequestInFlight = true + _isLoading.value = true + + compositeDisposable.add( + userRepository.updateContentPreference( + request = request, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + isRequestInFlight = false + _isLoading.value = false + + if (response.success) { + val syncedState = response.data?.let { + PreferenceState( + isAdultContentVisible = it.isAdultContentVisible, + contentType = it.contentType + ) + } ?: targetState + + confirmedState = syncedState + if (pendingState == null) { + applyLocalState(syncedState) + } + flushPendingState() + } else { + rollbackToConfirmedState(response.message) + } + }, + { + isRequestInFlight = false + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + rollbackToConfirmedState(null) + } + ) + ) + } + + private fun rollbackToConfirmedState(errorMessage: String?) { + pendingState = null + syncRunnable?.let { handler.removeCallbacks(it) } + syncRunnable = null + + applyLocalState(confirmedState) + _toastLiveData.postValue( + errorMessage?.let { ToastMessage(message = it) } ?: ToastMessage(resId = R.string.retry) + ) + } + + private fun applyLocalState(state: PreferenceState) { + _isAdultContentVisible.value = state.isAdultContentVisible + _adultContentPreference.value = state.contentType + SharedPreferenceManager.isAdultContentVisible = state.isAdultContentVisible + SharedPreferenceManager.contentPreference = state.contentType.ordinal + isChangedAdultContentVisible = state != initialState + } + + private fun getCurrentPreferenceState(): PreferenceState { + val localContentType = ContentType.entries.getOrNull(SharedPreferenceManager.contentPreference) + ?: ContentType.ALL + + return PreferenceState( + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = localContentType + ) + } + + override fun onCleared() { + syncRunnable?.let { handler.removeCallbacks(it) } + syncRunnable = null + super.onCleared() + } + + companion object { + private const val CONTENT_PREFERENCE_DEBOUNCE_DELAY_MS = 400L } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt index 6d4c00fb..f690b96f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt @@ -84,7 +84,8 @@ class SettingsActivity : BaseActivity(ActivitySettingsB ) } - if (SharedPreferenceManager.isAuth) { + val isNotKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } != "KR" + if (SharedPreferenceManager.isAuth || isNotKoreanCountry) { binding.dividerContentSettings.visibility = View.VISIBLE binding.rlContentSettings.visibility = View.VISIBLE binding.rlContentSettings.setOnClickListener { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt new file mode 100644 index 00000000..8933b70c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.settings + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class UpdateContentPreferenceRequest( + @SerializedName("isAdultContentVisible") val isAdultContentVisible: Boolean? = null, + @SerializedName("contentType") val contentType: ContentType? = null +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceResponse.kt new file mode 100644 index 00000000..4d4096b3 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceResponse.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.settings + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class UpdateContentPreferenceResponse( + @SerializedName("isAdultContentVisible") val isAdultContentVisible: Boolean, + @SerializedName("contentType") val contentType: ContentType +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt index 17319910..00e91bac 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.settings.notification import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kr.co.vividnext.sodalive.settings.ContentType @Keep data class GetMemberInfoResponse( @@ -18,7 +19,13 @@ data class GetMemberInfoResponse( @SerializedName("followingChannelUploadContentNotice") val followingChannelUploadContentNotice: Boolean?, @SerializedName("auditionNotice") - val auditionNotice: Boolean? + val auditionNotice: Boolean?, + @SerializedName("countryCode") + val countryCode: String, + @SerializedName("isAdultContentVisible") + val isAdultContentVisible: Boolean, + @SerializedName("contentType") + val contentType: ContentType ) enum class MemberRole { @@ -26,5 +33,5 @@ enum class MemberRole { USER, @SerializedName("CREATOR") - CREATOR, + CREATOR } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt index a0af2383..29341c89 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt @@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.mypage.block.GetBlockedMemberListResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest import kr.co.vividnext.sodalive.mypage.profile.nickname.GetChangeNicknamePriceResponse +import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceRequest +import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceResponse import kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponse import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.settings.signout.SignOutRequest @@ -26,6 +28,7 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.Multipart +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Part @@ -49,6 +52,12 @@ interface UserApi { @Header("Authorization") authHeader: String ): Single> + @PATCH("/member/content-preference") + fun updateContentPreference( + @Body request: UpdateContentPreferenceRequest, + @Header("Authorization") authHeader: String + ): Single> + @GET("/push/notification/categories") fun getPushNotificationCategories( @Header("Authorization") authHeader: String diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt index 86179b15..01f7cadf 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt @@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.main.PushTokenUpdateRequest import kr.co.vividnext.sodalive.mypage.MyPageResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest +import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceRequest +import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceResponse import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.settings.signout.SignOutRequest import kr.co.vividnext.sodalive.user.find_password.ForgotPasswordRequest @@ -38,6 +40,13 @@ class UserRepository(private val userApi: UserApi) { fun getMemberInfo(token: String) = userApi.getMemberInfo(authHeader = token) + fun updateContentPreference( + request: UpdateContentPreferenceRequest, + token: String + ): Single> { + return userApi.updateContentPreference(request = request, authHeader = token) + } + fun getPushNotificationCategories(token: String): Single> { return userApi.getPushNotificationCategories(authHeader = token) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 43ab87e7..d5276684 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -994,6 +994,9 @@ Log out from all devices? Content viewing settings Show sensitive content + Are you over 18? + This content is available only to users aged 18 and over! + To view sensitive content, turn on the Show sensitive content switch. All Male-oriented Female-oriented diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 8a608251..2f1cbb65 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -994,6 +994,9 @@ すべての端末からログアウトしますか? コンテンツ表示設定 センシティブなコンテンツ表示 + あなたは18歳以上ですか? + このコンテンツは18歳以上のみ利用できます! + センシティブなコンテンツを表示するには「センシティブなコンテンツ表示」スイッチをオンにしてください。 男性向け 女性向け diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c5f884c..88f3133a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -993,6 +993,9 @@ 모든 기기에서 로그아웃 하시겠어요? 콘텐츠 보기 설정 민감한 콘텐츠 보기 + 당신은 18세 이상입니까? + 해당 콘텐츠는 18세 이상만 이용이 가능합니다! + 민감한 콘텐츠를 보려면 민감한 콘텐츠 보기 스위치를 켜주세요. 전체 남성향 여성향 diff --git a/docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md b/docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md new file mode 100644 index 00000000..87f1e41c --- /dev/null +++ b/docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md @@ -0,0 +1,284 @@ +# 20260326_멤버정보응답확장및콘텐츠보기설정동기화.md + +## 개요 +- `/member/info` 응답 스펙 확장(`countryCode`, `isAdultContentVisible`, `contentType`)을 앱 데이터 모델과 로컬 상태에 반영한다. +- 설정 화면의 `콘텐츠 보기 설정` 노출 조건을 기존 `SharedPreferenceManager.isAuth == true` 유지 + `countryCode != "KR"` 조건 추가로 확장한다. +- `ContentSettings`에서 값 변경 시 `/member/content-preference`(PATCH) API로 서버 동기화를 수행하고, API 호출 구간에 `LoadingDialog`를 표시한다. +- 빠른 연타 상황에서는 **RxJava debounce를 사용하지 않고** 마지막 상태만 서버로 전송되도록 처리한다. + +## 요구사항 해석(확정) +- `/member/info` 응답 모델에 아래 필드를 추가한다. + - `countryCode: String` + - `isAdultContentVisible: Boolean` + - `contentType: ContentType` +- `/member/info`에서 수신한 `countryCode`, `isAdultContentVisible`, `contentType`을 로컬 저장소(`SharedPreferenceManager`)에 동기화한다. +- `SettingsActivity`의 `콘텐츠 보기 설정` 메뉴 노출은 아래 중 하나라도 만족하면 표시한다. + - `SharedPreferenceManager.isAuth == true` + - `countryCode != "KR"` +- `ContentSettingsActivity`에서 성인 콘텐츠 토글/콘텐츠 타입 변경 시 `/member/content-preference` PATCH를 호출한다. +- `/member/content-preference` PATCH 요청은 실제로 변경된 필드만 전송할 수 있도록 request 파라미터를 optional로 지원한다. +- API 호출 동안 Loading UI를 노출한다. +- 연속 입력 시 debounce 처리하여 마지막 값만 전송한다. +- debounce 구현은 RxJava 연산자(`debounce`, `switchMap` 등)를 사용하지 않는다. + +## 현재 구조 조사 요약 +- `UserApi.getMemberInfo()`는 이미 `@GET("/member/info")`로 연결되어 있고 응답은 `GetMemberInfoResponse`를 사용한다. +- `MainViewModel.getMemberInfo()`가 `SharedPreferenceManager.isAuth`를 갱신하는 현재 유일 경로다. +- `SettingsActivity`에서 `rlContentSettings` 노출 조건은 현재 `SharedPreferenceManager.isAuth` 단일 조건이다. +- `ContentSettingsActivity`/`ContentSettingsViewModel`은 현재 로컬 `SharedPreferenceManager`만 갱신하며 서버 PATCH 연동이 없다. +- `ContentSettingsViewModel`은 현재 `UserRepository`를 주입받지 않으며(`AppDI.kt`에서 `ContentSettingsViewModel()`), 로딩/오류 상태 LiveData도 없다. + +## 설계 결정 +- `GetMemberInfoResponse`를 확장하고, `MainViewModel.getMemberInfo()`에서 신규 필드를 `SharedPreferenceManager`에 동기화한다. +- 국가 코드 저장을 위해 `Constants.PREF_COUNTRY_CODE` + `SharedPreferenceManager.countryCode`를 추가한다. +- 설정 메뉴 노출 조건은 `isAuth || countryCode != "KR"`로 통합한다. + - `countryCode` 미존재/공백 시 기본값은 `"KR"`로 간주하여 기존 노출 정책을 깨지 않도록 한다. +- `/member/content-preference` PATCH API를 `UserApi`/`UserRepository`에 추가하고, 요청/응답 DTO를 분리한다. +- `ContentSettingsViewModel`에 `UserRepository`를 주입해 서버 동기화 책임을 이동한다. +- debounce는 RxJava 없이 `Handler(Looper.getMainLooper()) + Runnable`로 구현한다. + - 입력마다 이전 Runnable을 취소하고 지연 전송을 재등록한다. + - 지연 종료 시점의 최신 상태(`isAdultContentVisible`, `contentType`)만 전송한다. + - API 진행 중 추가 변경이 들어오면 최신 상태를 대기열로 유지하고, 현재 요청 완료 직후 마지막 상태 1회만 추가 전송한다. +- `LoadingDialog`는 **실제 PATCH 요청 수행 구간**에만 표시한다(디바운스 대기 시간에는 미표시). + +## 완료 기준 (Acceptance Criteria) +- [x] AC1: `/member/info` 응답 확장 필드 3개가 모델에 반영되고, 로컬 저장소 동기화까지 정상 동작한다. +- [x] AC2: 설정 화면에서 `isAuth == true` 또는 `countryCode != "KR"`이면 `콘텐츠 보기 설정` 메뉴가 노출된다. +- [x] AC3: `ContentSettings`의 토글/타입 변경이 `/member/content-preference` PATCH로 서버에 전달된다. +- [x] AC4: PATCH 요청 수행 중 `LoadingDialog`가 표시되고 완료/실패 시 정상 해제된다. +- [x] AC5: 연속 탭 시 마지막 상태만 서버로 전송된다. +- [x] AC6: debounce 구현에서 RxJava debounce 계열 연산자를 사용하지 않는다. + +## 구현 체크리스트 +### 1) `/member/info` 응답 모델 확장 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt` + - `countryCode`, `isAdultContentVisible`, `contentType` 필드 추가 +- [x] `contentType`은 현재 로컬에서 사용하는 `ContentType` enum과 동일 타입으로 사용하도록 매핑 정합을 유지한다. + +### 2) 로컬 저장소 키/상태 확장 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt` + - `PREF_COUNTRY_CODE` 상수 추가 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt` + - `countryCode: String` 프로퍼티 추가 + - 필요 시 멤버 정보 기반 기본값 동기화 로직 정의 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt` + - `getMemberInfo()` 성공 시 `countryCode`, `isAdultContentVisible`, `contentType`을 각각 `SharedPreferenceManager.countryCode`, `SharedPreferenceManager.isAdultContentVisible`, `SharedPreferenceManager.contentPreference`로 동기화한다. + +### 3) 설정 메뉴 노출 조건 확장 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt` + - `rlContentSettings` 표시 조건을 `isAuth || countryCode != "KR"`로 변경 + - 기존 숨김/표시 및 클릭 연결 흐름 유지 + +### 4) 콘텐츠 설정 PATCH API 추가 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt` + - `@PATCH("/member/content-preference")` API 정의 추가 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt` + - content preference 업데이트 메서드 추가 +- [x] 신규 DTO 파일 추가 + - 요청: `UpdateContentPreferenceRequest` (`isAdultContentVisible`, `contentType`) + - 응답: `UpdateContentPreferenceResponse` (`isAdultContentVisible`, `contentType`) + +### 5) ContentSettings 서버 동기화 + Non-Rx debounce +- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt` + - `UserRepository` 주입으로 생성자 변경 + - 로딩/오류 상태 LiveData 추가 + - 로컬 상태 반영 후 debounce 스케줄링 함수 추가(Handler/Runnable) + - in-flight + pending 상태를 분리해 마지막 상태 1회 전송 보장 + - `onCleared()`에서 callback 정리 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt` + - `viewModel { ContentSettingsViewModel(get()) }`로 DI 갱신 + +### 6) ContentSettings 화면 로딩 UI 반영 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt` + - `LoadingDialog` 초기화 및 `viewModel.isLoading` 관찰 + - 요청 중 표시/요청 완료 해제 처리 + +### 7) 검증 +- [ ] `lsp_diagnostics`로 수정 파일 신규 오류 확인 +- [x] `./gradlew :app:testDebugUnitTest` 실행 +- [x] `./gradlew :app:assembleDebug` 실행 +- [ ] 수동 QA + - 인증 사용자 + 비인증/해외 국가 코드 조합별 메뉴 노출 확인 + - 콘텐츠 설정 연타 시 네트워크 요청이 마지막 값 위주로 수렴되는지 확인 + - 요청 중 LoadingDialog 표시/해제 확인 + +### 8) 추가 요구사항 반영 (2026-03-27) +- [x] `app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt` + - 본인인증/인증완료 아이템(`btnIdentityVerification`)을 `countryCode == "KR"`일 때만 노출 + - 비KR에서는 본인인증 버튼을 숨김 처리 + +### 9) 콘텐츠 설정 PATCH optional request 반영 (2026-03-27) +- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt` + - `isAdultContentVisible`, `contentType`를 nullable optional 파라미터로 변경 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt` + - 변경된 필드만 request에 담아 PATCH 호출하도록 분기 + +### 10) 캐릭터 상세 진입 인증 체크 국가 분기 반영 (2026-03-27) +- [x] `app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt` + - 캐릭터 상세 진입 전 `ensureLoginAndAuth`를 KR/비KR 분기로 조정 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt` + - 캐릭터 상세 진입 전 `ensureLoginAndAuth`를 KR/비KR 분기로 조정 + +### 11) KR 인증완료 + 민감 콘텐츠 OFF 케이스 동작 일치화 (2026-03-27) +- [x] `app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt` + - `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 상태라도 `isAdultContentVisible == false`면 설정 가이드로 이동 + - `ensureLoginAndAuth`(캐릭터 상세)에서 KR 인증완료 상태라도 `isAdultContentVisible == false`면 설정 가이드로 이동 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt` + - `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/live/now/all/LiveNowAllActivity.kt` + - `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리 +- [x] `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt` + - `ensureLoginAndAuth`(캐릭터 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리 + +## 영향 파일(예상) +### 필수 +- `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt` + +### 신규 파일(예상) +- `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt` +- `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceResponse.kt` + +## 리스크 및 확인사항 +- `contentType`은 현재 로컬에서 사용하는 `ContentType`과 동일 타입을 사용한다. +- 멤버 정보 API 호출 시점은 앱 실행 후 3번째 실행 흐름으로, 메뉴 노출 조건 반영 타이밍상 문제는 없는 것으로 본다. +- PATCH 실패 시 직전 값으로 롤백한다(값 변경 시 API 호출 + LoadingDialog 표시 구간에서 사용자 대기가 가능한 UX를 전제로 함). +- 연타 + 네트워크 지연 상황에서 LoadingDialog 과도 점멸을 방지하기 위해 표시 조건을 실제 API 요청 구간으로 제한한다(반영). + +## 검증 계획 +- 정적 확인: 응답/요청 DTO 필드, 메뉴 노출 조건식, debounce 상태 변수 흐름 점검 +- 자동 검증: 단위 테스트 및 디버그 빌드 성공 확인 +- 수동 검증: 한국/비한국 노출 케이스, 토글 연타 케이스, 마지막 값 전송 케이스 + +## 검증 기록 +- 기록 템플릿(후속 누적): + - YYYY-MM-DD + - 무엇/왜/어떻게: + - 실행 명령/도구: + - `명령 또는 사용 도구` + - 결과: + +- 2026-03-26 + - 무엇/왜/어떻게: `/member/info` 응답 확장, 설정 메뉴 노출 확장, `/member/content-preference` PATCH, Non-Rx debounce 제약을 반영한 구현 계획 문서를 작성했다. + - 실행 명령/도구: + - `read(docs/*, UserApi.kt, GetMemberInfoResponse.kt, SettingsActivity.kt, ContentSettingsActivity.kt, ContentSettingsViewModel.kt, SharedPreferenceManager.kt, AppDI.kt)` + - `grep("/member/info|content-preference|isAuth|countryCode|ContentType")` + - `apply_patch(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md 생성)` + - 결과: + - 요구사항과 추가 제약("debounce는 RxJava 미사용")이 반영된 체크리스트 중심 계획 문서를 생성했다. + +- 2026-03-26 + - 무엇/왜/어떻게: 사용자 피드백에 따라 리스크/확인사항을 확정 대응안으로 갱신하고, `/member/info` 수신 필드 3종의 로컬 저장소 동기화 요구를 문서에 명시했다. + - 실행 명령/도구: + - `read(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md)` + - `apply_patch(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md 수정)` + - 결과: + - `contentType 동일 타입 사용`, `멤버 정보 호출 타이밍 이슈 없음`, `PATCH 실패 롤백`, `LoadingDialog API 구간 제한`이 문서에 반영되었다. + - `/member/info` 확장 필드(`countryCode`, `isAdultContentVisible`, `contentType`)의 로컬 저장소 동기화 요구가 AC/체크리스트에 반영되었다. + +- 2026-03-26 + - 무엇/왜/어떻게: 계획 문서 기준으로 `/member/info` 응답 확장 필드 로컬 동기화, 설정 메뉴 노출 조건 확장, `/member/content-preference` PATCH 연동, ContentSettings의 Non-Rx debounce + in-flight/pending 마지막 값 전송 보장, LoadingDialog 연동을 구현했다. + - 실행 명령/도구: + - `task(explore x2)` + - `grep/read(app/src/main/java/**)` + - `apply_patch(소스 12개 파일 + DTO 2개 파일)` + - `lsp_diagnostics(수정된 .kt 파일 일괄 시도)` + - `./gradlew :app:testDebugUnitTest` + - `./gradlew :app:assembleDebug` + - 결과: + - `GetMemberInfoResponse`에 `countryCode/isAdultContentVisible/contentType`가 추가되고 `MainViewModel.getMemberInfo()`에서 `SharedPreferenceManager`로 동기화된다. + - `SettingsActivity`의 `콘텐츠 보기 설정` 노출 조건이 `isAuth || countryCode != "KR"`로 확장되었다. + - `UserApi/UserRepository`에 `/member/content-preference` PATCH와 요청/응답 DTO가 추가되었다. + - `ContentSettingsViewModel`에 `Handler + Runnable` 기반 debounce와 in-flight/pending 제어를 적용해 연타 시 마지막 상태만 서버로 전송되며, 요청 중에만 `isLoading`이 true가 된다. + - `ContentSettingsActivity`가 `isLoading`을 관찰해 `LoadingDialog`를 표시/해제하고 오류 토스트를 노출한다. + - `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 실행 불가 메시지를 확인했다. + - 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다. + +- 2026-03-26 + - 무엇/왜/어떻게: 정적 검사 대체 검증과 실제 단말 스모크 실행으로 구현 안정성을 추가 확인했다. + - 실행 명령/도구: + - `./gradlew :app:ktlintCheck` + - `adb devices` + - `adb install -r app/build/outputs/apk/debug/app-debug.apk` + - `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1` + - `adb shell dumpsys activity | grep -E "ResumedActivity|mFocusedApp|topResumedActivity|mCurrentFocus"` + - `adb logcat -d -s AndroidRuntime` + - `adb shell pidof kr.co.vividnext.sodalive.debug` + - 결과: + - `ktlintCheck`는 기존 코드베이스(주로 `LiveRoomActivity` 등) 기등록 스타일 위반으로 실패했다. + - 이번 변경 파일 기준으로는 `ContentSettingsActivity`, `GetMemberInfoResponse` 지적 항목을 정리 후 재빌드하여 `:app:testDebugUnitTest`, `:app:assembleDebug` 재성공을 확인했다. + - 디버그 APK 설치 및 런처 실행(monkey) 후 `SplashActivity`가 resumed 상태이며 앱 프로세스(pid) 실행 중임을 확인했다. + +- 2026-03-27 + - 무엇/왜/어떻게: 추가 요청에 따라 `MyPageFragment`의 본인인증/인증완료 아이템을 한국 접속국가에서만 노출하도록 분기했다. + - 실행 명령/도구: + - `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)` + - `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)` + - `./gradlew :app:testDebugUnitTest` + - `./gradlew :app:assembleDebug` + - 결과: + - `countryCode.ifBlank { "KR" } == "KR"` 조건에서만 `btnIdentityVerification`이 표시되며, 비KR에서는 숨김 처리된다. + - `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다. + - 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다. + +- 2026-03-27 + - 무엇/왜/어떻게: `/member/content-preference` 요청에 변경된 값만 전송하기 위해 request 필드를 optional로 전환하고, `confirmedState` 대비 변경 필드만 body에 담아 PATCH 호출하도록 조정했다. + - 실행 명령/도구: + - `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt)` + - `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt)` + - `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt)` + - `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt)` + - `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 결과: + - `UpdateContentPreferenceRequest`의 `isAdultContentVisible`, `contentType`가 nullable optional로 변경되었다. + - `ContentSettingsViewModel.syncContentPreference()`에서 `confirmedState`와 비교해 변경된 필드만 request에 포함하며, 변경 필드가 없으면 API 호출을 생략한다. + - `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다. + - 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다. + +- 2026-03-27 + - 무엇/왜/어떻게: 캐릭터 터치 후 상세 진입 시 사용되는 `ensureLoginAndAuth` 인증 체크를 접속국가 기준으로 분기했다. KR은 기존 본인인증 다이얼로그 흐름을 유지하고, 비KR은 `isAdultContentVisible`이 꺼져 있으면 `ContentSettingsActivity`로 이동해 민감한 콘텐츠 스위치 안내 흐름을 사용하도록 맞췄다. + - 실행 명령/도구: + - `task(explore x2: 캐릭터 상세 진입 경로/인증 게이트 탐색)` + - `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt)` + - `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt)` + - `lsp_diagnostics(HomeFragment.kt, CharacterTabFragment.kt)` + - `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - `adb devices` + - `adb install -r app/build/outputs/apk/debug/app-debug.apk` + - `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1` + - `adb shell uiautomator dump /sdcard/window_dump.xml` + - `adb pull /sdcard/window_dump.xml /tmp/sodalive_window_dump.xml` + - 결과: + - `HomeFragment`와 `CharacterTabFragment`의 캐릭터 상세 진입 전 인증 체크가 `countryCode.ifBlank { "KR" } == "KR"` 기준으로 분기된다. + - KR에서 미인증이면 기존 인증 다이얼로그/인증 플로우를 유지한다. + - 비KR에서 `isAdultContentVisible == false`이면 `ContentSettingsActivity`로 이동하고 `EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE`를 전달한다. + - `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다. + - 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다. + - 수동 검증 시도 중 ADB 장치 연결이 해제되어 캐릭터 탭 실제 터치 시나리오를 끝까지 완료하지 못했다. + +- 2026-03-27 + - 무엇/왜/어떻게: 한국 접속 + 본인인증 완료 상태에서도 `isAdultContentVisible == false`이면 19금 라이브 상세와 채팅 캐릭터 상세를 비KR OFF와 동일하게 처리하도록 게이트 조건을 조정했다. + - 실행 명령/도구: + - `task(explore: live/character gate 위치 점검)` + - `grep("ensureLoginAndAdultAuth|ensureLoginAndAuth|onCharacterClick")` + - `apply_patch(HomeFragment.kt, LiveFragment.kt, LiveNowAllActivity.kt, CharacterTabFragment.kt)` + - `lsp_diagnostics(수정된 .kt 파일 4개)` + - `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - `adb devices` + - `adb shell am force-stop kr.co.vividnext.sodalive.debug` + - `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1` + - `adb shell uiautomator dump /sdcard/window_dump.xml` + - 결과: + - KR에서 `isAuth=true`라도 `isAdultContentVisible=false`이면 19금 라이브 상세/캐릭터 상세 진입 시 `ContentSettingsActivity` 안내 흐름으로 분기된다. + - 기존 KR 미인증 케이스의 본인인증 다이얼로그 흐름은 유지된다. + - 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 성공. + - `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다. + - 수동 QA는 디바이스 연결이 반복 해제되어 조건 기반 실제 탭 시나리오를 끝까지 수행하지 못했다.